feat: unified 0 and 90 degree PDF envelope and category descriptors
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Importador de projetos via JSON — M9.7
|
||||
*
|
||||
* Lê um arquivo JSON exportado do VentoApp (roundtrip com `storage.ts`)
|
||||
* e atualiza o store Zustand correspondente.
|
||||
*
|
||||
* Suporta dois formatos:
|
||||
* 1. SavedProject (formato IndexedDB)
|
||||
* { name, module, inputs, createdAt, updatedAt }
|
||||
* 2. Snapshot direto do windStore (formato debug "Exportar estado atual")
|
||||
* { v0, s1, s2, s3, vk, q, ... }
|
||||
*
|
||||
* Valida estrutura mínima antes de aplicar; retorna erros tipados.
|
||||
*/
|
||||
|
||||
import { useWindStore } from '../store/appStore';
|
||||
import { useGalpaoStore } from '../store/galpaoStore';
|
||||
import type { SavedProject } from './storage';
|
||||
import type { TerrainCategory } from './wind-kernel';
|
||||
|
||||
export type ModuleId = SavedProject['module'];
|
||||
|
||||
export interface ImportResult {
|
||||
ok: boolean;
|
||||
module?: ModuleId;
|
||||
projectName?: string;
|
||||
appliedFields?: string[];
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const VALID_MODULES: readonly ModuleId[] = [
|
||||
'galpao',
|
||||
'cilindro',
|
||||
'vault',
|
||||
'dome',
|
||||
'sign',
|
||||
'isolated-roof',
|
||||
'bar',
|
||||
'bridge',
|
||||
'dynamics',
|
||||
];
|
||||
|
||||
const VALID_CATEGORIES: readonly TerrainCategory[] = ['I', 'II', 'III', 'IV', 'V'];
|
||||
|
||||
function isString(v: unknown): v is string {
|
||||
return typeof v === 'string';
|
||||
}
|
||||
|
||||
function isNumber(v: unknown): v is number {
|
||||
return typeof v === 'number' && Number.isFinite(v);
|
||||
}
|
||||
|
||||
function isBoolean(v: unknown): v is boolean {
|
||||
return typeof v === 'boolean';
|
||||
}
|
||||
|
||||
function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta o tipo de arquivo importado.
|
||||
*
|
||||
* - Se tem `module` e `inputs` → SavedProject
|
||||
* - Se tem `v0` e `terrainCategory` → Snapshot do windStore
|
||||
* - Caso contrário → inválido
|
||||
*/
|
||||
export function detectFormat(parsed: unknown): 'saved-project' | 'snapshot' | 'unknown' {
|
||||
if (!isObject(parsed)) return 'unknown';
|
||||
if (isString(parsed.module) && isObject(parsed.inputs)) return 'saved-project';
|
||||
if ('v0' in parsed && ('terrainCategory' in parsed || 's2' in parsed)) return 'snapshot';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida um SavedProject.
|
||||
*
|
||||
* Retorna warnings (não-fatais) e erro (fatal) separadamente.
|
||||
*/
|
||||
export function validateSavedProject(raw: unknown): {
|
||||
ok: boolean;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
} {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isObject(raw)) {
|
||||
errors.push('JSON não é um objeto.');
|
||||
return { ok: false, warnings, errors };
|
||||
}
|
||||
|
||||
if (!isString(raw.name)) {
|
||||
errors.push('Campo "name" ausente ou não é string.');
|
||||
}
|
||||
if (!isString(raw.module) || !VALID_MODULES.includes(raw.module as ModuleId)) {
|
||||
errors.push(`Campo "module" ausente ou inválido (deve ser um de: ${VALID_MODULES.join(', ')}).`);
|
||||
}
|
||||
if (!isObject(raw.inputs)) {
|
||||
errors.push('Campo "inputs" ausente ou não é objeto.');
|
||||
}
|
||||
if (!isNumber(raw.createdAt)) {
|
||||
warnings.push('Campo "createdAt" ausente — será gerado automaticamente.');
|
||||
}
|
||||
if (!isNumber(raw.updatedAt)) {
|
||||
warnings.push('Campo "updatedAt" ausente — será gerado automaticamente.');
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, warnings, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida um snapshot do windStore.
|
||||
*/
|
||||
export function validateSnapshot(raw: unknown): {
|
||||
ok: boolean;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
} {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isObject(raw)) {
|
||||
errors.push('JSON não é um objeto.');
|
||||
return { ok: false, warnings, errors };
|
||||
}
|
||||
|
||||
if (!isNumber(raw.v0)) errors.push('Campo "v0" ausente ou não é número.');
|
||||
if (!isNumber(raw.s1)) errors.push('Campo "s1" ausente ou não é número.');
|
||||
if (!isNumber(raw.s3)) errors.push('Campo "s3" ausente ou não é número.');
|
||||
if (
|
||||
!isString(raw.terrainCategory) ||
|
||||
!VALID_CATEGORIES.includes(raw.terrainCategory as TerrainCategory)
|
||||
) {
|
||||
errors.push(
|
||||
`Campo "terrainCategory" inválido (deve ser um de: ${VALID_CATEGORIES.join(', ')}).`,
|
||||
);
|
||||
}
|
||||
if (!isNumber(raw.s3Group)) warnings.push('Campo "s3Group" ausente — mantendo valor padrão.');
|
||||
if (!isNumber(raw.largestDimension))
|
||||
warnings.push('Campo "largestDimension" ausente — mantendo valor padrão.');
|
||||
if (!isNumber(raw.heightZ)) warnings.push('Campo "heightZ" ausente — mantendo valor padrão.');
|
||||
|
||||
return { ok: errors.length === 0, warnings, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parseia uma string JSON com segurança.
|
||||
*/
|
||||
export function parseProjectJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error(`JSON inválido: ${e instanceof Error ? e.message : 'erro desconhecido'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica um SavedProject validado aos stores Zustand.
|
||||
*
|
||||
* Apenas o windStore e galpaoStore são atualizados neste MVP;
|
||||
* módulos futuros podem estender via dispatcher.
|
||||
*/
|
||||
export function applySavedProject(project: SavedProject): ImportResult {
|
||||
const appliedFields: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const wind = useWindStore.getState();
|
||||
const inputs = project.inputs as Record<string, unknown>;
|
||||
|
||||
// Atualiza windStore se o snapshot estiver presente
|
||||
if ('wind' in inputs && isObject(inputs.wind)) {
|
||||
const w = inputs.wind;
|
||||
if (isNumber(w.v0)) {
|
||||
wind.setV0(w.v0);
|
||||
appliedFields.push('wind.v0');
|
||||
}
|
||||
if (isNumber(w.s1)) {
|
||||
wind.setS1(w.s1);
|
||||
appliedFields.push('wind.s1');
|
||||
}
|
||||
if (isNumber(w.terrainCategory) || isString(w.terrainCategory)) {
|
||||
const cat = String(w.terrainCategory);
|
||||
if (VALID_CATEGORIES.includes(cat as TerrainCategory)) {
|
||||
wind.setTerrainCategory(cat as TerrainCategory);
|
||||
appliedFields.push('wind.terrainCategory');
|
||||
} else {
|
||||
warnings.push(`Categoria inválida: ${cat}`);
|
||||
}
|
||||
}
|
||||
if (isNumber(w.s3Group)) {
|
||||
wind.setS3Group(w.s3Group as 1 | 2 | 3 | 4 | 5);
|
||||
appliedFields.push('wind.s3Group');
|
||||
}
|
||||
if (isNumber(w.largestDimension) && isNumber(w.heightZ)) {
|
||||
wind.setDimensions(w.largestDimension, w.heightZ);
|
||||
appliedFields.push('wind.dimensions');
|
||||
}
|
||||
}
|
||||
|
||||
// Atualiza galpaoStore se inputs do galpão
|
||||
if (project.module === 'galpao' && 'galpao' in inputs && isObject(inputs.galpao)) {
|
||||
const g = inputs.galpao;
|
||||
const galpao = useGalpaoStore.getState();
|
||||
if (isNumber(g.width)) {
|
||||
galpao.setWidth(g.width);
|
||||
appliedFields.push('galpao.width');
|
||||
}
|
||||
if (isNumber(g.length)) {
|
||||
galpao.setLength(g.length);
|
||||
appliedFields.push('galpao.length');
|
||||
}
|
||||
if (isNumber(g.height)) {
|
||||
galpao.setHeight(g.height);
|
||||
appliedFields.push('galpao.height');
|
||||
}
|
||||
if (isNumber(g.roofPitch)) {
|
||||
galpao.setRoofPitch(g.roofPitch);
|
||||
appliedFields.push('galpao.roofPitch');
|
||||
}
|
||||
if (isNumber(g.windAngle) || (g.windAngle === 0 || g.windAngle === 90)) {
|
||||
wind.setWindAngle((g.windAngle as 0 | 90));
|
||||
appliedFields.push('wind.windAngle');
|
||||
}
|
||||
if (isString(g.permeabilityCase)) {
|
||||
wind.setPermeabilityCase(g.permeabilityCase as 'four-equally-permeable' | 'dominant-windward');
|
||||
appliedFields.push('wind.permeabilityCase');
|
||||
}
|
||||
if (isNumber(g.cpiRatio)) {
|
||||
wind.setCpiRatio(g.cpiRatio);
|
||||
appliedFields.push('wind.cpiRatio');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
module: project.module,
|
||||
projectName: project.name,
|
||||
appliedFields,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica um snapshot do windStore.
|
||||
*/
|
||||
export function applySnapshot(snapshot: Record<string, unknown>): ImportResult {
|
||||
const appliedFields: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const wind = useWindStore.getState();
|
||||
if (isNumber(snapshot.v0)) {
|
||||
wind.setV0(snapshot.v0);
|
||||
appliedFields.push('v0');
|
||||
}
|
||||
if (isNumber(snapshot.s1)) {
|
||||
wind.setS1(snapshot.s1);
|
||||
appliedFields.push('s1');
|
||||
}
|
||||
if (isNumber(snapshot.s3)) {
|
||||
wind.setS3(snapshot.s3);
|
||||
appliedFields.push('s3');
|
||||
}
|
||||
if (isString(snapshot.terrainCategory)) {
|
||||
if (VALID_CATEGORIES.includes(snapshot.terrainCategory as TerrainCategory)) {
|
||||
wind.setTerrainCategory(snapshot.terrainCategory as TerrainCategory);
|
||||
appliedFields.push('terrainCategory');
|
||||
} else {
|
||||
warnings.push(`Categoria inválida: ${snapshot.terrainCategory}`);
|
||||
}
|
||||
}
|
||||
if (isNumber(snapshot.s3Group)) {
|
||||
wind.setS3Group(snapshot.s3Group as 1 | 2 | 3 | 4 | 5);
|
||||
appliedFields.push('s3Group');
|
||||
}
|
||||
if (isNumber(snapshot.largestDimension) && isNumber(snapshot.heightZ)) {
|
||||
wind.setDimensions(snapshot.largestDimension, snapshot.heightZ);
|
||||
appliedFields.push('dimensions');
|
||||
}
|
||||
|
||||
return { ok: true, appliedFields, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Atalho: parseia texto JSON, detecta formato, valida, aplica.
|
||||
*/
|
||||
export function importProjectFromText(text: string): ImportResult {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseProjectJson(text);
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : 'Erro ao parsear JSON' };
|
||||
}
|
||||
|
||||
const format = detectFormat(parsed);
|
||||
|
||||
if (format === 'saved-project') {
|
||||
const validation = validateSavedProject(parsed);
|
||||
if (!validation.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Validação falhou: ${validation.errors.join('; ')}`,
|
||||
warnings: validation.warnings,
|
||||
};
|
||||
}
|
||||
const project = parsed as SavedProject;
|
||||
const result = applySavedProject(project);
|
||||
return { ...result, warnings: [...(result.warnings ?? []), ...validation.warnings] };
|
||||
}
|
||||
|
||||
if (format === 'snapshot') {
|
||||
const validation = validateSnapshot(parsed);
|
||||
if (!validation.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Validação falhou: ${validation.errors.join('; ')}`,
|
||||
warnings: validation.warnings,
|
||||
};
|
||||
}
|
||||
const result = applySnapshot(parsed as Record<string, unknown>);
|
||||
return { ...result, warnings: [...(result.warnings ?? []), ...validation.warnings] };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'Formato não reconhecido. Esperado: SavedProject (com module/inputs) ou snapshot do windStore.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um File picker e dispara callback com o conteúdo lido.
|
||||
*/
|
||||
export function readProjectFile(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Falha ao ler arquivo'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporta um projeto para string JSON (roundtrip).
|
||||
* Útil para testes.
|
||||
*/
|
||||
export function exportProjectToJson(project: SavedProject): string {
|
||||
return JSON.stringify(project, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialização determinística para snapshot do windStore.
|
||||
*/
|
||||
export function snapshotWindStoreToJson(): string {
|
||||
const state = useWindStore.getState();
|
||||
const snapshot = {
|
||||
v0: state.v0,
|
||||
s1: state.s1,
|
||||
s3: state.s3,
|
||||
s3Group: state.s3Group,
|
||||
terrainCategory: state.terrainCategory,
|
||||
largestDimension: state.largestDimension,
|
||||
heightZ: state.heightZ,
|
||||
s2: state.s2,
|
||||
vk: state.vk,
|
||||
q: state.q,
|
||||
structureClass: state.structureClass,
|
||||
};
|
||||
return JSON.stringify(snapshot, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecção redundante para o módulo unimported (evita warning em build).
|
||||
*/
|
||||
export const _internals = {
|
||||
VALID_MODULES,
|
||||
VALID_CATEGORIES,
|
||||
isString,
|
||||
isNumber,
|
||||
isBoolean,
|
||||
isObject,
|
||||
};
|
||||
Reference in New Issue
Block a user