Compare commits

..

No commits in common. "608c9b5e851e931a1c86f79fd7700f53a7640ad9" and "ff068446869621eec9aba1810628d18fdf6d32d4" have entirely different histories.

93 changed files with 4557 additions and 27974 deletions

View File

@ -4425,48 +4425,14 @@ namespace AgroBase.Models
private void IniciarRetornoBaseCore(GPSModel baseGps, List<GPSModel> waypointsManual)
{
bool valido = false;
List<GPSModel> pontosRetorno = null;
List<GPSModel> waypointsManualPersistir =
null;
// =========================
// MODO MANUAL
// MODO MANUAL (operador)
// =========================
if (
waypointsManual != null &&
waypointsManual.Count > 0
)
if (waypointsManual != null && waypointsManual.Count > 0)
{
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();
pontosRetorno = ValidarEPrepararWaypointsRetorno(waypointsManual, "manual");
valido = true;
}
else
@ -4475,49 +4441,19 @@ 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."
);
throw new InvalidOperationException("Trajetoria de retorno invalida ou rota segura indisponivel.");
}
else
{
pontosRetorno = ValidarEPrepararWaypointsRetorno(pontosRetorno, "calculado");
AplicarTrajetoriaFixaRetorno(pontosRetorno, "RetornoBase", waypointsManual?.Any() ?? false);
AtualizarDadosOperacaoParaRetorno();
}
pontosRetorno =
ValidarEPrepararWaypointsRetorno(
pontosRetorno,
"calculado"
);
AplicarTrajetoriaFixaRetorno(
pontosRetorno,
"RetornoBase",
waypointsManualPersistir?.Any()
?? false
);
AtualizarDadosOperacaoParaRetorno(
waypointsManualPersistir
);
}
private List<GPSModel> ValidarEPrepararWaypointsRetorno(List<GPSModel> pontos, string origem)
@ -4687,7 +4623,7 @@ namespace AgroBase.Models
return new List<GPSModel>(pontos);
}
private void AtualizarDadosOperacaoParaRetorno(List<GPSModel> waypointsManual)
private void AtualizarDadosOperacaoParaRetorno()
{
var op = Variaveis.OperacaoEmAndamento;
@ -4704,26 +4640,6 @@ 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,6 +11,9 @@ 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:
@ -41,83 +44,110 @@ 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,
require_product_contract=False,
**kwargs,
):
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
# 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.module_calibration_json = module_calibration_json
self.module_params = self._load_module_params(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.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=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,
imu_modo=self.imu_modo,
imu_freq_hz=self.imu_freq_hz,
hardware_sync_enabled=hardware_sync_enabled,
frame_sync_master=frame_sync_master,
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,
buffer_size=buffer_size,
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,
**kwargs,
)
self.applied_camera_controls = {}
self.radiometric_controller = None
self.radiometric_controller_enabled = False
self.core = RawProcessorCore(
sensor_width=width,
sensor_height=height,
bayer_pattern=bayer,
sensor_width=self.rgb_native_width,
sensor_height=self.rgb_native_height,
bayer_pattern=self.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=width,
sensor_height=height,
bayer_pattern=bayer,
sensor_width=self.rgb_native_width,
sensor_height=self.rgb_native_height,
bayer_pattern=self.bayer,
)
self._preview_cache = {
(self.rgb_native_width, self.rgb_native_height, self.bayer): self.preview,
}
def __enter__(self):
self.start()
@ -126,19 +156,247 @@ class OakFcc3Client:
def __exit__(self, exc_type, exc, tb):
self.stop()
def _load_module_params(self, path):
if not path or not os.path.isfile(path):
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}"
)
return {}
with open(path, "r", encoding="utf-8") as f:
return json.load(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),
}
def apply_module_camera_settings(self):
camera_settings = self.module_params.get("camera_settings", {}) or {}
applied = {}
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}"
)
for role, settings in camera_settings.items():
applied = {}
roles = ("rgb", "re", "nir") if self.product_contract else tuple(camera_settings.keys())
for role in roles:
settings = camera_settings.get(role)
if not isinstance(settings, dict):
continue
@ -157,17 +415,43 @@ 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):
@ -207,17 +491,45 @@ class OakFcc3Client:
)
try:
self.mx_id = self.svc.manager.mx_id
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)
except Exception:
pass
# Se a validação de contrato falhar, fecha hardware antes de propagar.
try:
self.svc.disconnect()
except Exception:
pass
raise
# 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)
self.enable_radiometric_controller()
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
)
return resp
@ -229,7 +541,29 @@ class OakFcc3Client:
return self.svc.get_device_metrics()
def get_status(self):
return self.svc.get_status()
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
def get_next_raw_frame(self, timeout=1.0):
return self.svc.capture_frame(timeout=timeout)
@ -244,6 +578,12 @@ 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)
@ -340,6 +680,25 @@ 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,
@ -355,41 +714,49 @@ 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)
tensor = self.core.fuse_multispec_cameras(decoded, meta, channels_expected)
tensor = self.core.resize_tensor_chw(tensor, target_size=target_size)
# 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 {}
# 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 um resultado antigo parecer referente ao frame atual.
self.core.last_frame_quality_result = None
# 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,
}
return tensor
return np.ascontiguousarray(
tensor.astype(np.float32, copy=False)
)
def decode_stream_cameras(self, frame, meta):
if str(meta.get("frame_type", self.frame_type)).upper() == "PREVIEW":
@ -505,10 +872,14 @@ 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", self.width)),
sensor_height=int(info.get("height", self.height)),
sensor_width=int(info.get("width", role_size[0])),
sensor_height=int(info.get("height", role_size[1])),
)
if role == "rgb":
@ -570,7 +941,7 @@ class OakFcc3Client:
or cam_meta.get("bayer")
or stream_meta.get("bayer_pattern")
or bayer_pattern
or "RGGB"
or self.bayer
)
bayer = str(bayer).upper()
@ -602,19 +973,17 @@ class OakFcc3Client:
real_w = int(cam_meta.get("width", sensor_width))
real_h = int(cam_meta.get("height", sensor_height))
core = RawProcessorCore(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
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
preview = RawProcessorPreview(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
raw16 = core.unpack_raw10_packed(
raw16 = self.core.unpack_raw10_packed(
packed,
sensor_width=real_w,
sensor_height=real_h,
@ -681,6 +1050,11 @@ 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.
@ -729,6 +1103,11 @@ 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,27 +12,60 @@ import numpy as np
from itertools import product
OAK_FCC3_MANAGER_VERSION = "production_v1_2026_08_24"
class OakFcc3Manager:
"""
Manager OAK-FFC-3 com dois fluxos principais:
Hardware manager de produção para OAK-FFC-3 multiespectral.
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.
Contrato oficial:
CAM_A = RGB = OV9782 1280x800 OU AR0234 1920x1200
CAM_B = RE = OV9282 1280x800
CAM_C = NIR = OV9282 1280x800
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.
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.
"""
SENSOR_W = 1280
SENSOR_H = 800
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,
},
}
def __init__(
self,
@ -44,41 +77,75 @@ class OakFcc3Manager:
capture_mode="AUTO",
raw_policy="allow_single",
roles=None,
sync_mode=None,
hardware_sync_enabled=None,
frame_sync_master=None,
sync_tolerance_ms=None,
buffer_size=None,
sync_mode="best",
hardware_sync_enabled=True,
frame_sync_master="CAM_A",
sync_tolerance_ms=12.0,
buffer_size=8,
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.fps = fps
self.hardware_sync_enabled = bool(hardware_sync_enabled)
self.frame_sync_master = str(frame_sync_master).upper()
self.fps = float(fps)
# 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.
# width/height são apenas saída LEGADA. RAW_BRUTO usa resolução nativa.
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 = capture_mode
self.raw_policy = raw_policy
self.capture_mode = str(capture_mode).upper()
self.raw_policy = str(raw_policy).lower()
self.only_camera = only_camera
self.roles = roles or {
"CAM_A": "rgb",
"CAM_B": "re",
"CAM_C": "nir",
}
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.mx_id = str(mx_id) if mx_id else None
self.dev_info = None
@ -90,14 +157,12 @@ 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
@ -105,32 +170,18 @@ 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._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._hydrate_camera_controls_from_module_params()
self._validate_static_product_contract()
self.async_capture_enabled = True
self.async_capture_mode = "latest" # latest | queue
@ -177,11 +228,327 @@ class OakFcc3Manager:
# ============================================================
def _load_module_params(self, path):
if not path or not os.path.isfile(path):
if not 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:
return json.load(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
def _default_controls_for_role(self, role: str):
role = str(role).lower()
@ -212,12 +579,31 @@ class OakFcc3Manager:
with dai.Device(dev_info) as dev:
result = []
for f in dev.getConnectedCameraFeatures():
features = dev.getConnectedCameraFeatures()
for row in self._feature_rows(features):
role = str(row.get("role", "unknown")).lower()
expected = self.expected_camera_hardware.get(role)
result.append({
"socket": f.socket.name,
"sensor": f.sensorName,
"role": self.roles.get(f.socket.name, "unknown"),
**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"]
)
)
),
})
return result
def _device_id_from_info(self, dev_info):
@ -291,35 +677,48 @@ class OakFcc3Manager:
def _create_camera_node_classic(self, socket, sensor_name: str, role: str):
"""
Fluxo clássico.
RAW/PREVIEW sensor-aware.
RGB/OV9782:
ColorCamera raw para RAW_BRUTO.
Produto:
OV9782 -> ColorCamera THE_800_P 1280x800
AR0234 -> ColorCamera THE_1200_P 1920x1200
OV9282 -> MonoCamera THE_800_P 1280x800
MONO/OV9282:
MonoCamera raw quando disponível.
No produto não existe fallback silencioso de resolução.
"""
sensor_name_u = str(sensor_name or "").upper()
role_u = str(role or "").lower()
sensor = str(sensor_name or "").upper()
role = str(role or "").lower()
is_rgb = (
role_u == "rgb"
or "OV9782" in sensor_name_u
or socket == dai.CameraBoardSocket.CAM_A
)
if self.product_contract:
profile = self._sensor_profile(sensor, role=role)
else:
profile = self.SENSOR_PROFILES.get(sensor)
if is_rgb:
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":
cam = self.pipeline.createColorCamera()
cam.setBoardSocket(socket)
try:
cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_800_P)
except Exception:
try:
cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_1080_P)
except Exception:
pass
resolution = getattr(
dai.ColorCameraProperties.SensorResolution,
profile["resolution_name"],
None,
)
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))
@ -331,31 +730,42 @@ 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)
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
resolution = getattr(
dai.MonoCameraProperties.SensorResolution,
profile["resolution_name"],
None,
)
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 hasattr(mono, "raw"):
return mono, mono.raw
if not hasattr(mono, "raw"):
if self.product_contract:
raise RuntimeError(
f"MonoCamera {sensor} não expõe raw output."
)
return mono, mono.out
return mono, mono.out
return mono, mono.raw
def _create_imu_node(self, pipeline):
self.has_imu_pipeline = False
@ -896,6 +1306,13 @@ 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()
@ -943,11 +1360,44 @@ 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": f.sensorName,
"sensor": sensor_u,
"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()
@ -1149,6 +1599,14 @@ 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,
@ -1726,6 +2184,32 @@ 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
@ -1733,6 +2217,18 @@ 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,
@ -1921,18 +2417,65 @@ 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 not ae:
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:
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,17 +1,15 @@
using OperationControl.Mapas;
using OperationControl.Mapas.Services;
using OperationControl.Models;
using OperationControl.Models;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media.Animation;
using Application = System.Windows.Application;
using Binding = System.Windows.Data.Binding;
using UserControl = System.Windows.Controls.UserControl;
using KeyEventArgs = System.Windows.Input.KeyEventArgs;
using Binding = System.Windows.Data.Binding;
using Application = System.Windows.Application;
using Point = System.Windows.Point;
using Size = System.Windows.Size;
using UserControl = System.Windows.Controls.UserControl;
namespace OperationControl.Controls
{
@ -317,8 +315,7 @@ namespace OperationControl.Controls
}
else
{
var ruasPercorrer = ((App)Application.Current).Shell.Dock?._vm?._mapas?.ObterRuasPercorrer() ?? new List<string>();
if (!(TgManual.IsChecked ?? false) && !ruasPercorrer.Any())
if (!(TgManual.IsChecked ?? false) && !(((App)Application.Current).Shell.Main?.MAP?.RuasMapaCarregado?.Any(x => x.Selected) ?? 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);
return;

View File

@ -2,18 +2,9 @@
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"
Focusable="True"
FocusVisualStyle="{x:Null}"
PreviewMouseLeftButtonDown="Root_PreviewMouseLeftButtonDown"
PreviewMouseMove="Root_PreviewMouseMove"
PreviewMouseLeftButtonUp="Root_PreviewMouseLeftButtonUp"
LostMouseCapture="Root_LostMouseCapture"
PreviewKeyDown="Root_PreviewKeyDown"
>
MinWidth="300">
<Grid>
<!-- Mapsui MapControl preenche todo o espaço disponível (responsivo) -->
<m:MapControl x:Name="Mapa"/>
@ -26,70 +17,7 @@
<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" Visibility="Collapsed"/>
<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="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

View File

@ -1,722 +0,0 @@
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();
}
}
}

View File

@ -1,599 +0,0 @@
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

@ -1,767 +0,0 @@
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

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

View File

@ -1,85 +0,0 @@
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

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

View File

@ -1,94 +0,0 @@
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

@ -1,341 +0,0 @@
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

@ -1,41 +0,0 @@
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

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

View File

@ -1,77 +0,0 @@
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

@ -1,76 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,794 +0,0 @@
<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

@ -1,517 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,86 +0,0 @@
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

@ -1,51 +0,0 @@
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

@ -1,171 +0,0 @@
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

@ -1,88 +0,0 @@
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

@ -1,95 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,30 +0,0 @@
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

@ -1,87 +0,0 @@
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

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

View File

@ -1,64 +0,0 @@
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

@ -1,33 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,30 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,53 +0,0 @@
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

@ -1,35 +0,0 @@
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

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

View File

@ -1,35 +0,0 @@
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

@ -1,261 +0,0 @@
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

@ -1,365 +0,0 @@
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

@ -1,606 +0,0 @@
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

@ -1,175 +0,0 @@
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

@ -1,287 +0,0 @@
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

@ -1,402 +0,0 @@
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

@ -1,55 +0,0 @@
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

@ -1,417 +0,0 @@
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

@ -1,275 +0,0 @@
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

@ -1,318 +0,0 @@
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

@ -1,110 +0,0 @@
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

@ -1,419 +0,0 @@
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

@ -1,752 +0,0 @@
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

@ -1,153 +0,0 @@
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

@ -1,527 +0,0 @@
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

@ -1,433 +0,0 @@
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

@ -1,417 +0,0 @@
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

@ -1,273 +0,0 @@
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,7 +11,6 @@ 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
@ -1123,15 +1122,16 @@ namespace OperationControl.Models
"Perda de comunicação com o equipamento!"
);
Application.Current?
.Dispatcher?
.BeginInvoke(
new Action(
() =>
{
Variaveis.Dock?._vm?.AtualizarStatusRoverMapa(rover.RoverId, StatusOperacao.Erro);
}
)
Variaveis.Dock?
._vm?
._viewOperacaoCenter?
.Mapa?
.markers?
.UpdateMarkerInfo(
rover.RoverId,
status:
AgroBase.Models.Enums
.StatusOperacao.Erro
);
}
else

View File

@ -36,7 +36,6 @@
<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,9 +43,6 @@
<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>
@ -88,9 +85,6 @@
<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,41 +589,15 @@ namespace OperationControl.Services
if (!fixar)
{
Models.Variaveis.MostrarLog(
"Configurando explicitamente o módulo como rover parado."
);
await BaseFix
.ConfigurarComoRoverParadoAsync(
portaUsb,
portaEntrada,
_lifetimeCts.Token
)
.ConfigureAwait(false);
DefinirPapelEsperado(
GnssExpectedRole.RoverTemporary
);
Models.Variaveis.MostrarLog("Configurando explicitamente o módulo como rover parado.");
_expectedRole = GnssExpectedRole.RoverTemporary;
await BaseFix.ConfigurarComoRoverParadoAsync(
portaUsb,
portaEntrada,
_lifetimeCts.Token).ConfigureAwait(false);
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;
@ -723,9 +697,7 @@ namespace OperationControl.Services
if (sucesso)
{
var snapshot = GetPositionSnapshot();
DefinirPapelEsperado(
GnssExpectedRole.BaseFixed
);
_expectedRole = GnssExpectedRole.BaseFixed;
_lastBaseConfiguration = new BaseFixedConfiguration
{
PortaUsb = portaUsb,
@ -799,86 +771,7 @@ namespace OperationControl.Services
await Task.Delay(1000, ct).ConfigureAwait(false);
await SendCommandsAsync(commands, 300, ct).ConfigureAwait(false);
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")}"
);
_expectedRole = GnssExpectedRole.BaseFixed;
}
// =========================================================
@ -2255,53 +2148,6 @@ 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 || AppShell.Mock;
get => _redeOk;
set
{
if (_redeOk != value)

View File

@ -19,16 +19,7 @@ namespace OperationControl.ViewModels.Views.Operacao.Monitoramento
if (_parametros != value)
{
_parametros = value;
AtualizarModoOperacaoAtual(
_parametros?.Modo
?? AgroBase.Models.Enums
.ModoOperacao.NaoDefinido
);
OnPropertyChanged(
nameof(Parametros)
);
OnPropertyChanged(nameof(Parametros));
}
}
}
@ -46,383 +37,34 @@ 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(), _ => !EdicaoMapaAtiva);
SalvarParametrosCommand =new RelayCommand(_ => SalvarParametrosSolicitado?.Invoke(Parametros), _ => !EdicaoMapaAtiva);
EditarMapaCommand = new RelayCommand(_ => EditarMapaSolicitado?.Invoke(), _ => PodeEditarMapa);
CancelarEdicaoMapaCommand = new RelayCommand(_ => CancelarEdicaoMapaSolicitado?.Invoke(), _ => EdicaoMapaAtiva);
LerParametrosCommand = new RelayCommand(_ => LerParametrosSolicitado?.Invoke());
SalvarParametrosCommand = new RelayCommand(_ => SalvarParametrosSolicitado?.Invoke(Parametros));
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);
@ -433,143 +75,13 @@ 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 AtualizarEstadoMapa(
int quantidadeRuas,
int quantidadeRuasPercurso,
bool edicaoAtiva,
int quantidadeSelecionadasEdicao,
bool edicaoModificada,
int quantidadeCorredores,
double distanciaOperacaoMetros,
double areaOperacaoHectares,
double larguraMediaCorredorMetros,
double? tempoEstimadoOperacaoSegundos)
public void ProcessarStreetMapClicked(MapViewControl.StreetMapClickedEventArgs e)
{
_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,6 +118,30 @@ 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,178 +339,31 @@ namespace OperationControl.ViewModels.Views.Operacao
}
else
{
/*
* =========================================================
* 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
)
)
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;
}
/*
* =========================================================
* 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)
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;
}
/*
* =========================================================
* 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 () =>
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
)
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
);
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
)
)
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
);
System.Windows.MessageBox.Show($"{VariaveisControleOperacao.RoverEmFoco?.DadosLeitura?.Operacao?.ImpedimentoErro}", "Operação iniciada com impedimento!", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
);
});
}
}
}
@ -533,7 +386,7 @@ namespace OperationControl.ViewModels.Views.Operacao
private void ReconfigurarGnss()
{
Variaveis.Dock?._vm?.IrParaConfiguracaoGnss();
Variaveis.Dock._vm.CurrentStep = DockStep.PreparacaoMapa;
}

View File

@ -6,7 +6,6 @@ using System.Globalization;
using System.Windows;
using System.Windows.Input;
using static OperationControl.Services.BaseFixService;
using static OperationControl.Services.GpsService;
namespace OperationControl.ViewModels
{
@ -27,60 +26,28 @@ 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 =
"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"
}
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" }
};
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 = "Carregue o mapa da operação e prepare o GNSS.";
StatusTexto = "Pronto para carregar o mapa e fixar a base.";
}
private MetodoFixacaoBase _metodoFixacaoSelecionado = MetodoFixacaoBase.Ntrip;
@ -118,258 +85,6 @@ 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
{
@ -387,45 +102,18 @@ 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 &&
!AlteracaoPapelGnssEmAndamento;
public bool PodeCarregarMapa => !FixacaoEmAndamento;
public bool PodeEditarMetodoFixacao =>
!FixacaoEmAndamento &&
!AlteracaoPapelGnssEmAndamento;
public bool PodeEditarMetodoFixacao => !FixacaoEmAndamento;
public bool PodeEditarCoordenadasManuais =>
!FixacaoEmAndamento &&
!AlteracaoPapelGnssEmAndamento &&
MetodoFixacaoSelecionado ==
MetodoFixacaoBase.Manual;
!FixacaoEmAndamento && MetodoFixacaoSelecionado == MetodoFixacaoBase.Manual;
public bool PodeEditarOffsetBase =>
!FixacaoEmAndamento &&
!AlteracaoPapelGnssEmAndamento;
public bool PodeEditarOffsetBase => !FixacaoEmAndamento;
public bool ExibirCamposManuais => MetodoFixacaoSelecionado == MetodoFixacaoBase.Manual;
@ -446,9 +134,6 @@ namespace OperationControl.ViewModels
{
get
{
if (AlteracaoPapelGnssEmAndamento)
return false;
if (FixacaoEmAndamento)
return true; // botão vira "Parar"
@ -468,23 +153,9 @@ namespace OperationControl.ViewModels
if (FixacaoEmAndamento)
return "Parar";
if (
MetodoFixacaoSelecionado ==
MetodoFixacaoBase.Manual
)
{
return PapelGnssAtual ==
GnssPapelEstacao.BaseFixa
? "Reposicionar base"
: "Aplicar posição da base";
}
return PapelGnssAtual ==
GnssPapelEstacao.BaseFixa
? "Reposicionar base"
: "Fixar como base";
return MetodoFixacaoSelecionado == MetodoFixacaoBase.Manual
? "Aplicar posição manual"
: "Fixar posição da base";
}
}
@ -781,68 +452,6 @@ 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 leitura";
private string _fixTexto = "Sem fix";
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" Latitude="-22.1726572492617" Longitude="-47.3952163870556" Scale="5000" />
<controls:MapViewControl x:Name="MapaMonitoramento" MarkerClicked="MAP_MarkerClicked" StreetMapClicked="MAP_StreetMapClicked" 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,6 +30,19 @@ 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,14 +68,6 @@
</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">
@ -212,145 +204,6 @@
</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" Latitude="-22.1726572492617" Longitude="-47.3952163870556" Scale="5000" />
<controls:MapViewControl x:Name="areaMapa" MarkerClicked="MAP_MarkerClicked" StreetMapClicked="MAP_StreetMapClicked" Latitude="-22.1726572492617" Longitude="-47.3952163870556" Scale="5000" />
</Border>
</Grid>

View File

@ -23,5 +23,16 @@ 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,786 +1,132 @@
<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"/>
<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 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"/>
</Style>
<!-- ===================================================== -->
<!-- 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 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"/>
</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="#1C1C1C">
<Grid Background="#181818" Margin="0">
<Grid.RowDefinitions>
<RowDefinition Height="64"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<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>
<!-- ===================================================== -->
<!-- CABEÇALHO -->
<!-- ===================================================== -->
<!-- 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}"/>
<Border
Grid.Row="0"
Background="#222222"
BorderBrush="#343434"
BorderThickness="0,0,0,1">
<!-- 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}"/>
<Grid Margin="12,0">
<!-- 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}"/>
<!-- ÁREA DINÂMICA -->
<Grid Grid.Column="3" Margin="0,12,12,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Progresso normal -->
<Grid Grid.Column="0" Visibility="{Binding ExibirProgresso, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="320"/>
<ColumnDefinition Width="50"/>
</Grid.ColumnDefinitions>
<!-- ============================================= -->
<!-- VOLTAR -->
<!-- ============================================= -->
<ProgressBar Grid.Column="0" Margin="0,8,8,0" Height="22" Minimum="0" Maximum="100" Value="{Binding ProgressoFixacao}"/>
<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">
<TextBlock Grid.Column="1" Text="{Binding ProgressoFixacaoTexto}" VerticalAlignment="Center" HorizontalAlignment="Center" Foreground="White" FontWeight="SemiBold" FontSize="14" Margin="0,0,8,0"/>
</Grid>
<Button.Template>
<!-- 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>
<ControlTemplate TargetType="Button">
<TextBlock Grid.Column="0" Text="Latitude (º):" Style="{StaticResource BottomLabelStyle}"/>
<Border
x:Name="Border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6">
<TextBox Grid.Column="1" Text="{Binding LatitudeManualTexto, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource BottomTextBoxStyle}" IsEnabled="{Binding PodeEditarCoordenadasManuais}"/>
<ContentPresenter
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="Longitude (º):" Style="{StaticResource BottomLabelStyle}"/>
</Border>
<TextBox Grid.Column="3" Text="{Binding LongitudeManualTexto, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource BottomTextBoxStyle}" IsEnabled="{Binding PodeEditarCoordenadasManuais}"/>
<TextBlock Grid.Column="4" Text="Altitude elipsoidal (m):" Style="{StaticResource BottomLabelStyle}"/>
<ControlTemplate.Triggers>
<TextBox Grid.Column="5" Text="{Binding AltitudeManualTexto, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource BottomTextBoxStyle}" IsEnabled="{Binding PodeEditarCoordenadasManuais}"/>
</Grid>
<Trigger
Property="IsMouseOver"
Value="True">
<!-- 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>
<Setter
TargetName="Border"
Property="Background"
Value="#353535"/>
<TextBlock Grid.Column="0" Text="Offset frontal (cm):" Style="{StaticResource BottomLabelStyle}"/>
<Setter
TargetName="Border"
Property="BorderBrush"
Value="#5A5A5A"/>
<TextBox Grid.Column="1" Text="{Binding OffsetFrontalBaseTexto, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource BottomTextBoxStyle}" IsEnabled="{Binding PodeEditarOffsetBase}"/>
</Trigger>
<TextBlock Grid.Column="2" Text="Offset lateral (cm):" Style="{StaticResource BottomLabelStyle}"/>
<TextBox Grid.Column="3" Text="{Binding OffsetLateralBaseTexto, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource BottomTextBoxStyle}" IsEnabled="{Binding PodeEditarOffsetBase}"/>
<Trigger
Property="IsEnabled"
Value="False">
<Setter
TargetName="Border"
Property="Opacity"
Value="0.35"/>
</Trigger>
</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>
<Button Grid.Column="4" Content="Aplicar Offset" Margin="12,0,12,0" Padding="5,5" MinWidth="120" Command="{Binding AplicarOffsetCommand}" />
</Grid>
</Grid>
</Grid>
</Border>
<!-- LINHA 2 -->
<Grid Grid.Row="1" Margin="12,4,12,10">
<Border Grid.Row="0" Padding="2" Background="#222" CornerRadius="6">
<!-- ===================================================== -->
<!-- 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>
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding StatusTexto}" Foreground="White" FontSize="14" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>

View File

@ -1,46 +0,0 @@
<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

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

View File

@ -1,254 +1,73 @@
<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"
mc:Ignorable="d">
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">
<UserControl.Resources>
<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>
<Style x:Key="MetricCardStyle"
TargetType="Border">
<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>
<Setter Property="Background"
Value="#292929"/>
<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="BorderBrush"
Value="#3A3A3A"/>
<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="BorderThickness"
Value="1"/>
<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="CornerRadius"
Value="8"/>
<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="Padding"
Value="12,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="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>
<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>
</StackPanel>
<!-- ================================================= -->
<!-- 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>
</ScrollViewer>
</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" 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" 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: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,6 +1856,26 @@ 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,8 +13,7 @@ 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 o RawProcessorPreview oficial;
- confirmar a orientação física/canônica das três câmeras;
- resolver/confirmar Bayer da RGB usando preview RAW10;
- 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;
@ -33,11 +32,6 @@ 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
@ -61,8 +55,6 @@ 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"
@ -264,8 +256,6 @@ class CameraSpec:
resolution_name: str
is_color: bool
preview_rotate_deg: int
preview_flip_horizontal: bool
preview_flip_vertical: bool
stream_name: str
@ -312,8 +302,6 @@ 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}",
)
@ -376,12 +364,77 @@ def build_preview_pipeline(specs: Dict[str, CameraSpec], fps: float):
return pipeline
def rgb_raw_preview(
pkt,
spec: CameraSpec,
processor: RawProcessorPreview,
):
"""Preview RGB oficial; não mantém um segundo mini-ISP neste script."""
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):
raw_type = str(pkt.getType()).upper()
if "PACK10" not in raw_type and "RAW10" not in raw_type:
raise RuntimeError(
@ -401,50 +454,31 @@ def rgb_raw_preview(
except Exception:
stride = None
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
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)
raw16 = processor.unpack_raw10_packed(
packed_data,
stride=stride,
)
return processor.raw16_to_preview_bgr(raw16)
# 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))
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"]
def apply_preview_orientation(img: np.ndarray, rotate_deg: int):
rotate_deg = int(rotate_deg) % 360
if rotate_deg == 90:
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)
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
def overlay(img, lines, x=12, y=24, scale=0.48, step=21):
@ -465,7 +499,6 @@ 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,
@ -473,25 +506,8 @@ 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 - Physical Sanity Check"
window = "Module Profile - Bayer Confirmation"
cv2.namedWindow(window, cv2.WINDOW_NORMAL)
try:
@ -522,11 +538,7 @@ def preview_and_confirm(
spec = specs[role]
pkt = latest[role]
if role == "rgb":
view = rgb_raw_preview(
pkt,
spec,
preview_processors[pattern],
)
view = rgb_raw_preview(pkt, spec, pattern, algorithm)
else:
view = pkt.getCvFrame()
if view.ndim == 2:
@ -534,7 +546,7 @@ def preview_and_confirm(
view = apply_preview_orientation(
view,
orientation[role],
spec.preview_rotate_deg,
)
panel = cv2.resize(
view,
@ -542,33 +554,16 @@ def preview_and_confirm(
interpolation=cv2.INTER_AREA,
)
lines = [
(
f"> {role.upper()} | {spec.socket} | {spec.sensor}"
if role == selected_role
else f" {role.upper()} | {spec.socket} | {spec.sensor}"
),
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} | PREVIEW=RawProcessorPreview",
f"BAYER={pattern} | DEMOSAIC={algorithm}",
"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(
@ -580,19 +575,14 @@ 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",
"",
"Confirme cores e o mesmo sentido fisico",
"nas tres cameras antes de pressionar ENTER.",
"Use um objeto vermelho e outro azul",
"para confirmar que R/B não estão trocados.",
])
last_board = np.vstack([
@ -607,48 +597,21 @@ def preview_and_confirm(
key = cv2.waitKey(1) & 0xFF
if key in (ord("q"), ord("Q"), 27):
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"
raise KeyboardInterrupt("Cancelado durante confirmação do Bayer.")
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"physical_check_{pattern}_{stamp()}.png"),
str(snapshots_dir / f"bayer_{pattern}_{stamp()}.png"),
last_board,
)
if key in (10, 13) and last_board is not None:
ensure_dir(snapshots_dir)
for role in ROLES:
orientation[role]["source"] = "raw_preview_confirmed"
confirmed_path = snapshots_dir / "physical_sanity_confirmed.png"
confirmed_path = snapshots_dir / "bayer_confirmed.png"
cv2.imwrite(str(confirmed_path), last_board)
return pattern, orientation, confirmed_path
return pattern, confirmed_path
time.sleep(0.002)
finally:
@ -662,9 +625,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_v5.json"),
"camera_startup_json": norm_path(profile_dir / "camera_startup_profile_v3.json"),
"homography_json": norm_path(profile_dir / "homography_calibration_v4.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"),
"module_params_json": norm_path(profile_dir / "module_params.json"),
"module_params_assembly_report_json": norm_path(
profile_dir / "module_params_assembly_report.json"
@ -689,27 +652,19 @@ 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: normalize_orientation(preview_orientation[role])
role: {
"rotate_deg": int(specs[role].preview_rotate_deg),
"flip_horizontal": False,
"flip_vertical": False,
"source": "sensor_default",
}
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,
@ -731,7 +686,10 @@ def build_profile(
"depthai_version": getattr(dai, "__version__", "unknown"),
"usb_speed": usb_speed,
"hardware_signature": hardware_signature,
"resolved_setup": resolved_setup,
"resolved_setup": {
role: asdict(specs[role])
for role in ROLES
},
"camera_inventory": rows,
"rgb_decode": {
"bayer_pattern": bayer,
@ -750,23 +708,9 @@ 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": (
"raw_preview_confirmed_until_homography"
if confirmed_with_raw_preview
else "default_or_cli_headless_until_homography"
),
"orientation_status": "preview_default_until_homography",
"note": (
"A Homography homologada será a autoridade final da orientação "
"científica e do espaço canônico."
@ -786,15 +730,6 @@ 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": {
@ -805,13 +740,6 @@ 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,
},
}
@ -861,29 +789,6 @@ 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.",
@ -921,25 +826,6 @@ 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",
@ -955,16 +841,6 @@ 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:
@ -973,7 +849,6 @@ 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()}"
@ -1023,13 +898,6 @@ 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
@ -1037,15 +905,10 @@ def main():
try:
if not args.no_preview:
(
selected_bayer,
selected_orientation,
confirmation_snapshot,
) = preview_and_confirm(
selected_bayer, 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,
@ -1063,7 +926,6 @@ 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,
)
@ -1103,14 +965,6 @@ 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,7 +108,6 @@ from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import shutil
import zipfile
@ -537,78 +536,6 @@ 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(
@ -675,11 +602,6 @@ 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],
@ -2680,10 +2602,6 @@ 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 = {}
@ -2696,7 +2614,6 @@ 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(),
@ -2864,9 +2781,6 @@ 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(
@ -3044,7 +2958,6 @@ def validate_final_module_params(
"bayer_pattern",
"sensor_size_by_role",
"camera_hardware",
"capture_synchronization",
"camera_orientation",
"rgb_processing",
"camera_settings",
@ -3100,15 +3013,6 @@ 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.")
@ -3794,9 +3698,6 @@ 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
),
@ -3931,9 +3832,6 @@ def main():
"camera_orientation": deepcopy(
camera_orientation
),
"capture_synchronization": deepcopy(
runtime_policy["capture_synchronization"]
),
"runtime_policy": runtime_policy,
"calibration_provenance": provenance,
"final_checks": {
@ -3949,7 +3847,6 @@ 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",
@ -3977,15 +3874,6 @@ 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,17 +41,12 @@ 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
@ -90,18 +85,14 @@ 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=None,
hardware_sync_enabled=None,
frame_sync_master=None,
sync_tolerance_ms=None,
buffer_size=None,
sync_mode="best",
hardware_sync_enabled=True,
frame_sync_master="CAM_A",
sync_tolerance_ms=12.0,
buffer_size=8,
only_camera=None,
mx_id=None,
module_calibration_json=None,
@ -56,6 +56,9 @@ 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.
@ -80,6 +83,10 @@ 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
@ -120,18 +127,6 @@ 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,689 +1,126 @@
#!/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:
VERSION = "raw_processor_preview_v2_2026_09_09"
CONTRACT_SCHEMA = "rgb_raw_annotation_preview_v2"
BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG")
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,
},
}
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()
@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
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}")
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}")
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 __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()
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,
self, raw16: np.ndarray,
black_level: Optional[int] = None,
white_level: Optional[int] = None,
gamma: float = 2.2,
bit_depth: int = 10
) -> 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}")
"""
Conversão para visualização:
- auto-level
- gamma
"""
max_val = float((1 << bit_depth) - 1)
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.")
raw = raw16.astype(np.float32)
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)
if black_level is None:
black_level = float(raw.min())
if white_level is None:
white_level = float(raw.max())
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)
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):
# 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))
"RGGB": cv2.COLOR_BayerRG2RGB_EA,
"BGGR": cv2.COLOR_BayerBG2RGB_EA,
"GRBG": cv2.COLOR_BayerGR2RGB_EA,
"GBRG": cv2.COLOR_BayerGB2RGB_EA,
}
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,
):
if self.bayer_pattern not in mapping:
raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}")
return mapping[self.bayer_pattern]
def apply_preview_white_balance(self, bgr: np.ndarray, strength: float = 1.0) -> np.ndarray:
"""
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.
Gray-world simples para deixar o preview mais agradável.
Não usar no raw de treino.
"""
image = np.asarray(bgr)
if image.ndim != 3 or image.shape[2] != 3:
raise ValueError(f"WB requer BGR HWC; shape={image.shape}")
img = bgr.astype(np.float32)
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_b = float(img[:, :, 0].mean())
mean_g = float(img[:, :, 1].mean())
mean_r = float(img[:, :, 2].mean())
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)
mean_gray = (mean_b + mean_g + mean_r) / 3.0
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"
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)
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
# 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
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
img[:, :, 0] *= gain_b
img[:, :, 1] *= gain_g
img[:, :, 2] *= gain_r
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)
return np.clip(img, 0, 255).astype(np.uint8)
@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 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
def raw16_to_preview_bgr(
self,
raw16: np.ndarray,
gamma: Optional[float] = None,
wb_strength: Optional[float] = None,
gamma: float = 2.2,
wb_strength: float = 0.8,
apply_wb: bool = True,
apply_contrast: bool = True,
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, 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=float(contrast_cfg["alpha"]),
beta=float(contrast_cfg["beta"]),
)
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"]),
)
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,
bit_depth: int = 10,
) -> np.ndarray:
"""
Desempacota RAW10 MIPI (4 pixels/5 bytes), aceitando ndarray 1D/2D,
bytes e stride/padding por linha.
Pipeline de preview bonito:
1. auto-level + gamma no mosaico
2. demosaic
3. white balance simples
4. leve contraste final
"""
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}")
vis8 = self.raw16_to_vis8(raw16, gamma=gamma, bit_depth=bit_depth)
bgr = cv2.cvtColor(vis8, self._debayer_code())
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]
if apply_wb:
bgr = self.apply_preview_white_balance(bgr, strength=wb_strength)
useful_width = (width // 4) * 5
if apply_contrast:
bgr = self.apply_preview_contrast(bgr, alpha=1.08, beta=0.0)
if packed.dtype != np.uint8:
raise TypeError(
f"RAW10 packed deve ser uint8; shape={packed.shape}, dtype={packed.dtype}"
)
return bgr
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))
def raw16_to_preview_jpg_bytes(self, raw16: np.ndarray, jpeg_quality: int = 95) -> bytes:
bgr = self.raw16_to_preview_bgr(raw16)
ok, encoded = cv2.imencode(
".jpg",
bgr,
[int(cv2.IMWRITE_JPEG_QUALITY), quality],
)
ok, enc = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(jpeg_quality)])
if not ok:
raise RuntimeError("Falha ao codificar preview JPG")
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()
return enc.tobytes()