diff --git a/README.md b/README.md index 8f8ac37..83d07c7 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,19 @@ If you are using ChatGPT, Claude, Gemini, Copilot, or other coding agents on thi | **Exports** | STL, STEP, IFC, PDF projection (PDF is currently native/Node-only — browser PDF is on the roadmap) | | **Integration** | Three.js scene management, WebAssembly-powered performance | +## Recent Highlights + +Recent improvements to the OpenGeometry kernel include: + +- ✅ Analytic geometry preservation (circles, arcs, cylinders, planar surfaces) +- ✅ STEP and IFC export of analytic geometry where supported +- ✅ Robust deterministic boolean operations with validation +- ✅ Multi-cutter boolean subtraction workflows +- ✅ B-Rep validity checking and topology healing +- ✅ Snapshot-based scenegraph with explicit refresh/replace semantics +- ✅ Stable entity registry with semantic projection metadata +- ✅ Improved Three.js integration and registry APIs + ## Demos See OpenGeometry in action — interactive, browser-based demos showcasing the kernel's capabilities: diff --git a/aia-layers.yaml b/aia-layers.yaml new file mode 100644 index 0000000..14d0887 --- /dev/null +++ b/aia-layers.yaml @@ -0,0 +1,306 @@ +# aia-layers.yaml +# Единый источник истины для AIA/NCS Layer Mapping + +layers: + # ===== Архитектурные элементы ===== + A-WALL: + - wall + - walls + - partition + + A-DOOR: + - door + - doors + + A-GLAZ: + - window + - windows + - glazing + - glaz + - glass + + A-GLAZ-CW: + - curtainwall + - curtain_wall + + A-GLAZ-SF: + - storefront + + A-FLOR: + - floor + - floors + - slab + - slabs + - floor_slab + + A-ROOF: + - roof + - roofs + + A-CEIL: + - ceiling + - ceilings + + A-FLOR-STRS: + - stair + - stairs + - staircase + - stairwell + + A-FLOR-RAIL: + - railing + - railings + - handrail + - guardrail + + A-FLOR-RAMP: + - ramp + - ramps + + A-COLS: + - column + - columns + - col + - pillar + + A-CASE: + - casework + - cabinet + - cabinets + - millwork + + A-FURN: + - furniture + - furn + - furnishings + + A-EQPM: + - equipment + - equip + - appliance + + A-OPEN: + - opening + - openings + + A-SHAF: + - shaft + - shafts + - chase + + A-PLMB: + - toilet + - restroom + - bathroom + - plumbing_fixture + + A-ELEV: + - elevator + - elev + + A-ESCL: + - escalator + + # ===== Инженерные системы ===== + M-DUCT: + - duct + - ducts + - hvac + - air_handler + - diffuser + + P-PIPE: + - pipe + - pipes + - plumbing + - plumb + + E-COND: + - conduit + - conduits + + E-CTRAY: + - cable_tray + - cabletray + - cable_trays + + E-LITE: + - lighting + - light + - lights + - luminaire + + F-SPRK: + - fire_protection + - sprinkler + - fire_alarm + + T-TELC: + - telecom + - telecommunications + - data + - network + + T-SEC: + - security + - camera + - access_control + + # ===== Структурные элементы ===== + S-FOOT: + - footing + - foundation + - footings + + S-GRDN: + - gradebeam + - grade_beam + + S-COLN: + - structural_column + - structural_columns + + S-BEAM: + - structural_beam + - structural_beams + - beam + - beams + + S-BRAC: + - brace + - bracing + - diagonal_brace + + S-TRUS: + - truss + - trusses + + S-GIRD: + - girder + - girders + + S-JOIS: + - joist + - joists + + S-DECK: + - deck + - decks + - metal_deck + + S-SHWR: + - shearwall + - shear_wall + + S-MOMF: + - moment_frame + - momentframe + + # ===== Земельные работы ===== + G-SITE: + - site + - grading + - topography + + G-ROAD: + - road + - roads + - pavement + - asphalt + + G-SWAL: + - sidewalk + - walkway + - path + + G-CURB: + - curb + - curbs + - gutter + + G-FENC: + - fence + - fencing + - gate + + G-LNDS: + - landscape + - planting + - plants + - tree + + G-PARK: + - parking + - parking_lot + - parking_stall + + G-RETW: + - retaining_wall + - retainingwall + + G-DRAN: + - drainage + - drain + - storm_drain + + # ===== Существующие и демонтируемые ===== + A-EXST: + - existing + - exist + - exst + + A-DEMO: + - demolish + - demo + - demolition + + A-TEMP: + - temporary + - temp + + A-FUTR: + - future + + # ===== Аннотации и документация ===== + A-ANNO: + - annotation + - annotations + - text + - label + + A-DIMN: + - dimension + - dim + - dimensions + + A-GRID: + - grid + - grids + - gridline + - refplane + + A-CALO: + - bubble + - bubbles + - callout + + A-SECM: + - section_mark + - sectionmark + + A-ELEM: + - elevation_mark + - elevationmark + + # ===== Специальные ===== + GENERAL: + - generic + - default + + HIDDEN: + - hidden + - invisible + + REFERENCE: + - reference + - ref diff --git a/main/opengeometry-three/__tests__/registry.test.ts b/main/opengeometry-three/__tests__/registry.test.ts new file mode 100644 index 0000000..6e8b86b --- /dev/null +++ b/main/opengeometry-three/__tests__/registry.test.ts @@ -0,0 +1,179 @@ +/** + * Тесты для OGEntityRegistry + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { OGEntityRegistry } from '../src/registry/index.js'; +import type { ViewDescription, EntityDescription } from '../src/registry/index.js'; + +// Мокаем WASM-модуль +vi.mock('opengeometry', () => ({ + OGEntityRegistry: vi.fn().mockImplementation(() => ({ + registerEntity: vi.fn(), + unregisterEntity: vi.fn().mockReturnValue(true), + clearEntities: vi.fn(), + projectCurrentToViews: vi.fn().mockReturnValue( + JSON.stringify({ + 'plan': { + name: 'plan', + segments: [ + { + geometry: { type: 'Line', start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }, + class: 'VisibleOutline', + layer: 'A-WALL', + sourceEntityId: 'wall-1' + } + ] + } + }) + ) + })) +})); + +describe('OGEntityRegistry', () => { + let registry: OGEntityRegistry; + + beforeEach(() => { + registry = new OGEntityRegistry(); + }); + + describe('registerEntity', () => { + it('should register entity without errors', () => { + expect(() => { + registry.registerEntity('test-1', 'wall', '{"vertices":[]}'); + }).not.toThrow(); + }); + }); + + describe('unregisterEntity', () => { + it('should return true when entity exists', () => { + const result = registry.unregisterEntity('test-1'); + expect(result).toBe(true); + }); + + it('should return false when entity does not exist', () => { + // Мокаем возврат false для несуществующей сущности + const mockUnregister = vi.fn().mockReturnValue(false); + const registry2 = new OGEntityRegistry(); + (registry2 as any).wasmRegistry.unregisterEntity = mockUnregister; + + const result = registry2.unregisterEntity('nonexistent'); + expect(result).toBe(false); + }); + }); + + describe('clearEntities', () => { + it('should clear all entities without errors', () => { + expect(() => { + registry.clearEntities(); + }).not.toThrow(); + }); + }); + + describe('projectViews', () => { + it('should project multiple views', () => { + const views: ViewDescription[] = [ + { + id: 'plan', + camera: { + position: [0, 10, 0], + target: [0, 0, 0], + up: [0, 0, -1], + near: 0.01, + projectionMode: 'Orthographic' + } + }, + { + id: 'elevation-front', + camera: { + position: [0, 0, 10], + target: [0, 0, 0], + up: [0, 1, 0], + near: 0.01, + projectionMode: 'Orthographic' + } + } + ]; + + const result = registry.projectViews(views); + expect(result).toHaveProperty('plan'); + expect(result).toHaveProperty('elevation-front'); + }); + }); + + describe('projectStandardViews', () => { + it('should generate standard views', () => { + const entities: EntityDescription[] = [ + { id: 'wall-1', kind: 'wall', brepJson: '{}' }, + { id: 'door-1', kind: 'door', brepJson: '{}' } + ]; + + const result = registry.projectStandardViews(entities); + + // Проверяем наличие стандартных видов + expect(result).toHaveProperty('plan'); + expect(result).toHaveProperty('elevation-front'); + expect(result).toHaveProperty('elevation-back'); + expect(result).toHaveProperty('elevation-left'); + expect(result).toHaveProperty('elevation-right'); + expect(result).toHaveProperty('isometric'); + }); + + it('should respect options', () => { + const entities: EntityDescription[] = [ + { id: 'wall-1', kind: 'wall', brepJson: '{}' } + ]; + + const result = registry.projectStandardViews(entities, { + includePlan: false, + includeElevations: false, + includeIsometric: false, + includeSections: true + }); + + // План отключен + expect(result).not.toHaveProperty('plan'); + + // Фасады отключены + expect(result).not.toHaveProperty('elevation-front'); + + // Изометрия отключена + expect(result).not.toHaveProperty('isometric'); + + // Разрезы включены + expect(result).toHaveProperty('section-horizontal'); + expect(result).toHaveProperty('section-vertical-x'); + }); + + it('should use custom target', () => { + const entities: EntityDescription[] = [ + { id: 'wall-1', kind: 'wall', brepJson: '{}' } + ]; + + const customTarget: [number, number, number] = [5, 2, -3]; + + // Проверяем, что функция не падает с кастомным target + expect(() => { + registry.projectStandardViews(entities, { + target: customTarget + }); + }).not.toThrow(); + }); + }); + + describe('error handling', () => { + it('should handle invalid JSON in BRep', () => { + const registry2 = new OGEntityRegistry(); + + // Мокаем ошибку WASM + const mockRegister = vi.fn().mockImplementation(() => { + throw new Error('Invalid BRep'); + }); + (registry2 as any).wasmRegistry.registerEntity = mockRegister; + + expect(() => { + registry2.registerEntity('test', 'wall', 'invalid json'); + }).toThrow(); + }); + }); +}); diff --git a/main/opengeometry-three/src/registry/index.ts b/main/opengeometry-three/src/registry/index.ts new file mode 100644 index 0000000..f341571 --- /dev/null +++ b/main/opengeometry-three/src/registry/index.ts @@ -0,0 +1,279 @@ +// main/opengeometry-three/src/registry/index.ts +import { OGEntityRegistry as WasmRegistry } from 'opengeometry'; + +/** + * Описание камеры для проекции + */ +export interface CameraDescription { + /** Позиция камеры в 3D пространстве [x, y, z] */ + position: [number, number, number]; + /** Точка, на которую смотрит камера [x, y, z] */ + target: [number, number, number]; + /** Вектор "вверх" [x, y, z] */ + up: [number, number, number]; + /** Ближняя плоскость отсечения */ + near: number; + /** Режим проекции: ортографическая или перспективная */ + projectionMode: 'Orthographic' | 'Perspective'; +} + +/** + * Опции скрытия линий (HLR - Hidden Line Removal) + */ +export interface HlrOptions { + /** Скрывать ли скрытые ребра */ + hideHiddenEdges: boolean; +} + +/** + * Плоскость сечения для разрезов + */ +export interface SectionPlane { + /** Точка на плоскости [x, y, z] */ + origin: [number, number, number]; + /** Нормаль к плоскости [x, y, z] */ + normal: [number, number, number]; +} + +/** + * Описание одного видового окна для проекции + */ +export interface ViewDescription { + /** Уникальный идентификатор вида */ + id: string; + /** Параметры камеры */ + camera: CameraDescription; + /** Опции скрытия линий (опционально) */ + hlr?: HlrOptions; + /** Плоскость сечения для разрезов (опционально) */ + sectionPlane?: SectionPlane; +} + +/** + * Геометрический сегмент в 2D проекции + */ +export type Segment2D = + | { type: 'Line'; start: { x: number; y: number }; end: { x: number; y: number } } + | { type: 'Arc'; center: { x: number; y: number }; radius: number; startAngle: number; endAngle: number } + | { type: 'Ellipse'; center: { x: number; y: number }; rx: number; ry: number; rotation: number; startAngle: number; endAngle: number } + | { type: 'CubicBezier'; p0: { x: number; y: number }; p1: { x: number; y: number }; p2: { x: number; y: number }; p3: { x: number; y: number } }; + +/** + * Классифицированный сегмент с атрибуцией + */ +export interface ClassifiedSegment { + /** Геометрия сегмента */ + geometry: Segment2D; + /** Класс ребра по ISO 128 */ + class: 'VisibleOutline' | 'VisibleCrease' | 'VisibleSmooth' | 'Hidden' | 'SectionCut'; + /** AIA/NCS слой (например "A-WALL") */ + layer: string | null; + /** ID исходной сущности в BRep */ + sourceEntityId: string | null; +} + +/** + * Результат проекции для одного вида + */ +export interface ViewResult { + /** Имя вида */ + name: string | null; + /** Массив классифицированных сегментов */ + segments: ClassifiedSegment[]; +} + +/** + * Результат пакетной проекции - карта "ID вида -> результат" + */ +export type ProjectionResult = Record; + +/** + * Описание сущности для регистрации + */ +export interface EntityDescription { + /** Уникальный идентификатор сущности */ + id: string; + /** Тип сущности (для определения слоя) */ + kind: string; + /** BRep в формате JSON (сериализованный) */ + brepJson: string; +} + +/** + * Опции для проекции стандартных видов + */ +export interface StandardViewsOptions { + /** Расстояние камеры от центра (по умолчанию 10) */ + orthographicDistance?: number; + /** Включать ли вид сверху (план) */ + includePlan?: boolean; + /** Включать ли фасады (4 стороны) */ + includeElevations?: boolean; + /** Включать ли 3D-изометрию */ + includeIsometric?: boolean; + /** Центр сцены для ориентации камер */ + target?: [number, number, number]; +} + +/** + * Реестр сущностей для пакетной многовидовой проекции. + * + * @example + * ```typescript + * const registry = new OGEntityRegistry(); + * + * // Регистрация сущностей + * registry.registerEntity('wall-1', 'wall', wallBrepJson); + * registry.registerEntity('door-1', 'door', doorBrepJson); + * + * // Создание видов + * const views = registry.projectViews([ + * { + * id: 'plan', + * camera: { + * position: [0, 10, 0], + * target: [0, 0, 0], + * up: [0, 0, -1], + * near: 0.01, + * projectionMode: 'Orthographic' + * }, + * hlr: { hideHiddenEdges: true } + * }, + * { + * id: 'elevation-front', + * camera: { + * position: [0, 0, 10], + * target: [0, 0, 0], + * up: [0, 1, 0], + * near: 0.01, + * projectionMode: 'Orthographic' + * } + * } + * ]); + * + * // Получение сегментов с атрибуцией + * const planSegments = views['plan'].segments; + * for (const seg of planSegments) { + * console.log(`Layer: ${seg.layer}, Source: ${seg.sourceEntityId}`); + * } + * ``` + */ +export class OGEntityRegistry { + private wasmRegistry: WasmRegistry; + + constructor() { + this.wasmRegistry = new WasmRegistry(); + } + + /** + * Зарегистрировать (или заменить) сущность в реестре + * kind будет нормализован (приведен к lowercase) в Rust + */ + registerEntity(id: string, kind: string, brepJson: string): void { + this.wasmRegistry.registerEntity(id, kind, brepJson); + } + + /** + * Удалить сущность из реестра + */ + unregisterEntity(id: string): boolean { + return this.wasmRegistry.unregisterEntity(id); + } + + /** + * Очистить все сущности из реестра + */ + clearEntities(): void { + this.wasmRegistry.clearEntities(); + } + + /** + * Выполнить пакетную проекцию для нескольких видов за один вызов WASM + */ + projectViews(views: ViewDescription[]): ProjectionResult { + const viewsJson = JSON.stringify(views); + const resultJson = this.wasmRegistry.projectCurrentToViews(viewsJson); + return JSON.parse(resultJson); + } + + /** + * Создать временный реестр и выполнить проекцию стандартных видов + * + * @param entities - Массив сущностей для регистрации + * @param options - Опции генерации видов + * @returns Результаты проекции + * + * @note Этот метод не изменяет текущий реестр, он создает временный + */ + static projectStandardViews( + entities: EntityDescription[], + options: StandardViewsOptions = {} + ): ProjectionResult { + // Создаем временный реестр + const tempRegistry = new OGEntityRegistry(); + + // Регистрируем все сущности + for (const entity of entities) { + tempRegistry.registerEntity(entity.id, entity.kind, entity.brepJson); + } + + const views: ViewDescription[] = []; + const dist = options.orthographicDistance ?? 10; + const target: [number, number, number] = options.target ?? [0, 0, 0]; + + // План (вид сверху) + if (options.includePlan !== false) { + views.push({ + id: 'plan', + camera: { + position: [0, dist, 0], + target: target, + up: [0, 0, -1], + near: 0.01, + projectionMode: 'Orthographic', + }, + hlr: { hideHiddenEdges: true }, + }); + } + + // Фасады (4 стороны) + if (options.includeElevations !== false) { + const elevations: Array<{ id: string; pos: [number, number, number] }> = [ + { id: 'elevation-front', pos: [0, 0, dist] }, + { id: 'elevation-back', pos: [0, 0, -dist] }, + { id: 'elevation-left', pos: [-dist, 0, 0] }, + { id: 'elevation-right', pos: [dist, 0, 0] }, + ]; + for (const elev of elevations) { + views.push({ + id: elev.id, + camera: { + position: elev.pos, + target: target, + up: [0, 1, 0], + near: 0.01, + projectionMode: 'Orthographic', + }, + hlr: { hideHiddenEdges: true }, + }); + } + } + + // Изометрия + if (options.includeIsometric !== false) { + views.push({ + id: 'isometric', + camera: { + position: [dist, dist * 0.7, dist] as [number, number, number], + target: target, + up: [0, 1, 0], + near: 0.01, + projectionMode: 'Orthographic', + }, + hlr: { hideHiddenEdges: true }, + }); + } + + return tempRegistry.projectViews(views); + } +} diff --git a/main/opengeometry-three/src/registry/types.ts b/main/opengeometry-three/src/registry/types.ts new file mode 100644 index 0000000..219a681 --- /dev/null +++ b/main/opengeometry-three/src/registry/types.ts @@ -0,0 +1,248 @@ +// main/opengeometry-three/src/registry/layers.ts +// Auto-generated from aia-layers.yaml + +export enum AIALayer { + A_ANNO = 'A-ANNO', + A_CALO = 'A-CALO', + A_CASE = 'A-CASE', + A_CEIL = 'A-CEIL', + A_COLS = 'A-COLS', + A_DEMO = 'A-DEMO', + A_DIMN = 'A-DIMN', + A_DOOR = 'A-DOOR', + A_ELEM = 'A-ELEM', + A_ELEV = 'A-ELEV', + A_EQPM = 'A-EQPM', + A_ESCL = 'A-ESCL', + A_EXST = 'A-EXST', + A_FLOR = 'A-FLOR', + A_FLOR_RAIL = 'A-FLOR-RAIL', + A_FLOR_RAMP = 'A-FLOR-RAMP', + A_FLOR_STRS = 'A-FLOR-STRS', + A_FURN = 'A-FURN', + A_FUTR = 'A-FUTR', + A_GLAZ = 'A-GLAZ', + A_GLAZ_CW = 'A-GLAZ-CW', + A_GLAZ_SF = 'A-GLAZ-SF', + A_GRID = 'A-GRID', + A_OPEN = 'A-OPEN', + A_PLMB = 'A-PLMB', + A_ROOF = 'A-ROOF', + A_SECM = 'A-SECM', + A_SHAF = 'A-SHAF', + A_TEMP = 'A-TEMP', + A_WALL = 'A-WALL', + E_COND = 'E-COND', + E_CTRAY = 'E-CTRAY', + E_LITE = 'E-LITE', + F_SPRK = 'F-SPRK', + G_CURB = 'G-CURB', + G_DRAN = 'G-DRAN', + G_FENC = 'G-FENC', + G_LNDS = 'G-LNDS', + G_PARK = 'G-PARK', + G_RETW = 'G-RETW', + G_ROAD = 'G-ROAD', + G_SITE = 'G-SITE', + G_SWAL = 'G-SWAL', + GENERAL = 'GENERAL', + HIDDEN = 'HIDDEN', + M_DUCT = 'M-DUCT', + P_PIPE = 'P-PIPE', + REFERENCE = 'REFERENCE', + S_BEAM = 'S-BEAM', + S_BRAC = 'S-BRAC', + S_COLN = 'S-COLN', + S_DECK = 'S-DECK', + S_FOOT = 'S-FOOT', + S_GIRD = 'S-GIRD', + S_GRDN = 'S-GRDN', + S_JOIS = 'S-JOIS', + S_MOMF = 'S-MOMF', + S_SHWR = 'S-SHWR', + S_TRUS = 'S-TRUS', + T_SEC = 'T-SEC', + T_TELC = 'T-TELC', +} + +export const ENTITY_TO_LAYER: Record = { + 'wall': AIALayer.A_WALL, + 'walls': AIALayer.A_WALL, + 'partition': AIALayer.A_WALL, + 'door': AIALayer.A_DOOR, + 'doors': AIALayer.A_DOOR, + 'window': AIALayer.A_GLAZ, + 'windows': AIALayer.A_GLAZ, + 'glazing': AIALayer.A_GLAZ, + 'glaz': AIALayer.A_GLAZ, + 'glass': AIALayer.A_GLAZ, + 'curtainwall': AIALayer.A_GLAZ_CW, + 'curtain_wall': AIALayer.A_GLAZ_CW, + 'storefront': AIALayer.A_GLAZ_SF, + 'floor': AIALayer.A_FLOR, + 'floors': AIALayer.A_FLOR, + 'slab': AIALayer.A_FLOR, + 'slabs': AIALayer.A_FLOR, + 'floor_slab': AIALayer.A_FLOR, + 'roof': AIALayer.A_ROOF, + 'roofs': AIALayer.A_ROOF, + 'ceiling': AIALayer.A_CEIL, + 'ceilings': AIALayer.A_CEIL, + 'stair': AIALayer.A_FLOR_STRS, + 'stairs': AIALayer.A_FLOR_STRS, + 'staircase': AIALayer.A_FLOR_STRS, + 'stairwell': AIALayer.A_FLOR_STRS, + 'railing': AIALayer.A_FLOR_RAIL, + 'railings': AIALayer.A_FLOR_RAIL, + 'handrail': AIALayer.A_FLOR_RAIL, + 'guardrail': AIALayer.A_FLOR_RAIL, + 'ramp': AIALayer.A_FLOR_RAMP, + 'ramps': AIALayer.A_FLOR_RAMP, + 'column': AIALayer.A_COLS, + 'columns': AIALayer.A_COLS, + 'col': AIALayer.A_COLS, + 'pillar': AIALayer.A_COLS, + 'casework': AIALayer.A_CASE, + 'cabinet': AIALayer.A_CASE, + 'cabinets': AIALayer.A_CASE, + 'millwork': AIALayer.A_CASE, + 'furniture': AIALayer.A_FURN, + 'furn': AIALayer.A_FURN, + 'furnishings': AIALayer.A_FURN, + 'equipment': AIALayer.A_EQPM, + 'equip': AIALayer.A_EQPM, + 'appliance': AIALayer.A_EQPM, + 'opening': AIALayer.A_OPEN, + 'openings': AIALayer.A_OPEN, + 'shaft': AIALayer.A_SHAF, + 'shafts': AIALayer.A_SHAF, + 'chase': AIALayer.A_SHAF, + 'toilet': AIALayer.A_PLMB, + 'restroom': AIALayer.A_PLMB, + 'bathroom': AIALayer.A_PLMB, + 'plumbing_fixture': AIALayer.A_PLMB, + 'elevator': AIALayer.A_ELEV, + 'elev': AIALayer.A_ELEV, + 'escalator': AIALayer.A_ESCL, + 'duct': AIALayer.M_DUCT, + 'ducts': AIALayer.M_DUCT, + 'hvac': AIALayer.M_DUCT, + 'air_handler': AIALayer.M_DUCT, + 'diffuser': AIALayer.M_DUCT, + 'pipe': AIALayer.P_PIPE, + 'pipes': AIALayer.P_PIPE, + 'plumbing': AIALayer.P_PIPE, + 'plumb': AIALayer.P_PIPE, + 'conduit': AIALayer.E_COND, + 'conduits': AIALayer.E_COND, + 'cable_tray': AIALayer.E_CTRAY, + 'cabletray': AIALayer.E_CTRAY, + 'cable_trays': AIALayer.E_CTRAY, + 'lighting': AIALayer.E_LITE, + 'light': AIALayer.E_LITE, + 'lights': AIALayer.E_LITE, + 'luminaire': AIALayer.E_LITE, + 'fire_protection': AIALayer.F_SPRK, + 'sprinkler': AIALayer.F_SPRK, + 'fire_alarm': AIALayer.F_SPRK, + 'telecom': AIALayer.T_TELC, + 'telecommunications': AIALayer.T_TELC, + 'data': AIALayer.T_TELC, + 'network': AIALayer.T_TELC, + 'security': AIALayer.T_SEC, + 'camera': AIALayer.T_SEC, + 'access_control': AIALayer.T_SEC, + 'footing': AIALayer.S_FOOT, + 'foundation': AIALayer.S_FOOT, + 'footings': AIALayer.S_FOOT, + 'gradebeam': AIALayer.S_GRDN, + 'grade_beam': AIALayer.S_GRDN, + 'structural_column': AIALayer.S_COLN, + 'structural_columns': AIALayer.S_COLN, + 'structural_beam': AIALayer.S_BEAM, + 'structural_beams': AIALayer.S_BEAM, + 'beam': AIALayer.S_BEAM, + 'beams': AIALayer.S_BEAM, + 'brace': AIALayer.S_BRAC, + 'bracing': AIALayer.S_BRAC, + 'diagonal_brace': AIALayer.S_BRAC, + 'truss': AIALayer.S_TRUS, + 'trusses': AIALayer.S_TRUS, + 'girder': AIALayer.S_GIRD, + 'girders': AIALayer.S_GIRD, + 'joist': AIALayer.S_JOIS, + 'joists': AIALayer.S_JOIS, + 'deck': AIALayer.S_DECK, + 'decks': AIALayer.S_DECK, + 'metal_deck': AIALayer.S_DECK, + 'shearwall': AIALayer.S_SHWR, + 'shear_wall': AIALayer.S_SHWR, + 'moment_frame': AIALayer.S_MOMF, + 'momentframe': AIALayer.S_MOMF, + 'site': AIALayer.G_SITE, + 'grading': AIALayer.G_SITE, + 'topography': AIALayer.G_SITE, + 'road': AIALayer.G_ROAD, + 'roads': AIALayer.G_ROAD, + 'pavement': AIALayer.G_ROAD, + 'asphalt': AIALayer.G_ROAD, + 'sidewalk': AIALayer.G_SWAL, + 'walkway': AIALayer.G_SWAL, + 'path': AIALayer.G_SWAL, + 'curb': AIALayer.G_CURB, + 'curbs': AIALayer.G_CURB, + 'gutter': AIALayer.G_CURB, + 'fence': AIALayer.G_FENC, + 'fencing': AIALayer.G_FENC, + 'gate': AIALayer.G_FENC, + 'landscape': AIALayer.G_LNDS, + 'planting': AIALayer.G_LNDS, + 'plants': AIALayer.G_LNDS, + 'tree': AIALayer.G_LNDS, + 'parking': AIALayer.G_PARK, + 'parking_lot': AIALayer.G_PARK, + 'parking_stall': AIALayer.G_PARK, + 'retaining_wall': AIALayer.G_RETW, + 'retainingwall': AIALayer.G_RETW, + 'drainage': AIALayer.G_DRAN, + 'drain': AIALayer.G_DRAN, + 'storm_drain': AIALayer.G_DRAN, + 'existing': AIALayer.A_EXST, + 'exist': AIALayer.A_EXST, + 'exst': AIALayer.A_EXST, + 'demolish': AIALayer.A_DEMO, + 'demo': AIALayer.A_DEMO, + 'demolition': AIALayer.A_DEMO, + 'temporary': AIALayer.A_TEMP, + 'temp': AIALayer.A_TEMP, + 'future': AIALayer.A_FUTR, + 'annotation': AIALayer.A_ANNO, + 'annotations': AIALayer.A_ANNO, + 'text': AIALayer.A_ANNO, + 'label': AIALayer.A_ANNO, + 'dimension': AIALayer.A_DIMN, + 'dim': AIALayer.A_DIMN, + 'dimensions': AIALayer.A_DIMN, + 'grid': AIALayer.A_GRID, + 'grids': AIALayer.A_GRID, + 'gridline': AIALayer.A_GRID, + 'refplane': AIALayer.A_GRID, + 'bubble': AIALayer.A_CALO, + 'bubbles': AIALayer.A_CALO, + 'callout': AIALayer.A_CALO, + 'section_mark': AIALayer.A_SECM, + 'sectionmark': AIALayer.A_SECM, + 'elevation_mark': AIALayer.A_ELEM, + 'elevationmark': AIALayer.A_ELEM, + 'generic': AIALayer.GENERAL, + 'default': AIALayer.GENERAL, + 'hidden': AIALayer.HIDDEN, + 'invisible': AIALayer.HIDDEN, + 'reference': AIALayer.REFERENCE, + 'ref': AIALayer.REFERENCE, +}; + +export function getLayerForEntity(kind: string): AIALayer | null { + const normalized = kind.toLowerCase().trim(); + return ENTITY_TO_LAYER[normalized] || null; +} diff --git a/main/opengeometry/Cargo.toml b/main/opengeometry/Cargo.toml index 6ab6379..6aacd22 100644 --- a/main/opengeometry/Cargo.toml +++ b/main/opengeometry/Cargo.toml @@ -23,6 +23,10 @@ earcutr = "=0.3.0" dxf = "0.6.0" stl_io = "0.8" boolmesh = "0.1.7" +phf = { version = "0.11", features = ["macros"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] printpdf = "0.5" + +[build-dependencies] +serde_yaml = "0.9" diff --git a/main/opengeometry/benches/registry_benchmark.rs b/main/opengeometry/benches/registry_benchmark.rs new file mode 100644 index 0000000..00cfa61 --- /dev/null +++ b/main/opengeometry/benches/registry_benchmark.rs @@ -0,0 +1,78 @@ +// benches/registry_benchmark.rs +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use opengeometry::scenegraph::OGEntityRegistry; +use opengeometry::export::projection::CameraParameters; +use opengeometry::brep::{Brep, BrepBuilder}; +use openmaths::Vector3; +use uuid::Uuid; +use serde_json; + +fn create_cube_brep(size: f64) -> Brep { + let h = size / 2.0; + let vertices = vec![ + Vector3::new(-h, -h, -h), + Vector3::new( h, -h, -h), + Vector3::new( h, h, -h), + Vector3::new(-h, h, -h), + Vector3::new(-h, -h, h), + Vector3::new( h, -h, h), + Vector3::new( h, h, h), + Vector3::new(-h, h, h), + ]; + + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&vertices); + + let faces = vec![ + vec![0, 1, 2, 3], + vec![4, 5, 6, 7], + vec![0, 1, 5, 4], + vec![2, 3, 7, 6], + vec![0, 3, 7, 4], + vec![1, 2, 6, 5], + ]; + + for face in faces { + builder.add_wire(&face, true).unwrap(); + } + + builder.build().unwrap() +} + +fn benchmark_batch_projection(c: &mut Criterion) { + let mut registry = OGEntityRegistry::new(); + + // Регистрируем 100 сущностей + for i in 0..100 { + let brep = create_cube_brep(1.0 + (i % 5) as f64); + let brep_json = serde_json::to_string(&brep).unwrap(); + registry.register_entity( + format!("entity-{}", i), + "wall".to_string(), + brep_json, + ).unwrap(); + } + + let camera = CameraParameters::default(); + let camera_json = serde_json::to_string(&camera).unwrap(); + + // Создаем 10 видов + let mut views = Vec::new(); + for i in 0..10 { + views.push(format!( + r#"{{"id":"view-{}","camera":{}}}"#, + i, camera_json + )); + } + let views_json = format!("[{}]", views.join(",")); + + c.bench_function("batch_projection_100_entities_10_views", |b| { + b.iter(|| { + let result = registry.project_current_to_views(black_box(views_json.clone())).unwrap(); + black_box(result) + }) + }); +} + +criterion_group!(benches, benchmark_batch_projection); +criterion_main!(benches); diff --git a/main/opengeometry/build.rs b/main/opengeometry/build.rs new file mode 100644 index 0000000..c132479 --- /dev/null +++ b/main/opengeometry/build.rs @@ -0,0 +1,77 @@ +// main/opengeometry/build.rs +use std::env; +use std::fs; +use std::path::Path; + +fn main() { + // Указываем, что нужно пересобрать при изменении layers.yaml + println!("cargo:rerun-if-changed=layers.yaml"); + + let out_dir = env::var_os("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("aia_layers.rs"); + + // Читаем YAML + let yaml_content = match fs::read_to_string("layers.yaml") { + Ok(content) => content, + Err(e) => { + eprintln!("Warning: layers.yaml not found: {}", e); + // Создаем пустой файл, чтобы компиляция прошла + fs::write(&dest_path, "// layers.yaml not found\npub fn aia_layer(_kind: &str) -> Option<&'static str> { None }\n") + .unwrap(); + return; + } + }; + + // Парсим YAML вручную без serde_yaml (чтобы избежать лишних зависимостей) + let mut rust_code = String::new(); + rust_code.push_str("// Auto-generated from layers.yaml\n"); + rust_code.push_str("// DO NOT EDIT MANUALLY\n\n"); + rust_code.push_str("use phf::{phf_map, Map};\n\n"); + rust_code.push_str("/// AIA/NCS Layer mapping - compile-time perfect hash map\n"); + rust_code.push_str("pub static AIA_LAYER_MAP: Map<&'static str, &'static str> = phf_map! {\n"); + + // Парсим YAML построчно + let mut current_layer = String::new(); + let mut in_layers = false; + + for line in yaml_content.lines() { + let line = line.trim(); + + if line == "layers:" { + in_layers = true; + continue; + } + + if !in_layers { + continue; + } + + if line.is_empty() { + continue; + } + + // Проверяем, что это слой (заканчивается на ':') + if line.ends_with(':') && !line.starts_with('-') { + current_layer = line.trim_end_matches(':').trim().to_string(); + continue; + } + + // Проверяем, что это значение (начинается с '-') + if line.starts_with('-') && !current_layer.is_empty() { + let value = line.trim_start_matches('-').trim(); + if !value.is_empty() { + rust_code.push_str(&format!(" \"{}\" => \"{}\",\n", value.to_lowercase(), current_layer)); + } + } + } + + rust_code.push_str("};\n\n"); + rust_code.push_str("/// Map entity kind to AIA/NCS layer code. O(1) lookup, no allocations.\n"); + rust_code.push_str("/// Kind must be already normalized to lowercase.\n"); + rust_code.push_str("pub fn aia_layer(kind: &str) -> Option<&'static str> {\n"); + rust_code.push_str(" AIA_LAYER_MAP.get(kind).copied()\n"); + rust_code.push_str("}\n"); + + fs::write(&dest_path, rust_code).unwrap(); + println!("cargo:warning=Generated AIA layers at {:?}", dest_path); +} diff --git a/main/opengeometry/layers.yaml b/main/opengeometry/layers.yaml new file mode 100644 index 0000000..1247b13 --- /dev/null +++ b/main/opengeometry/layers.yaml @@ -0,0 +1,50 @@ +# main/opengeometry/layers.yaml +layers: + A-WALL: + - wall + - walls + - partition + A-DOOR: + - door + - doors + A-GLAZ: + - window + - windows + - glazing + - glaz + - glass + A-FLOR: + - floor + - floors + - slab + - slabs + A-ROOF: + - roof + - roofs + A-COLS: + - column + - columns + - col + - pillar + S-BEAM: + - structural_beam + - structural_beams + - beam + - beams + M-DUCT: + - duct + - ducts + - hvac + P-PIPE: + - pipe + - pipes + - plumbing + G-SITE: + - site + - grading + A-EXST: + - existing + - exist + GENERAL: + - generic + - default diff --git a/main/opengeometry/src/export/projection.rs b/main/opengeometry/src/export/projection.rs index bf29586..b4d9dbd 100644 --- a/main/opengeometry/src/export/projection.rs +++ b/main/opengeometry/src/export/projection.rs @@ -1,3 +1,4 @@ +// main/opengeometry/src/export/projection.rs use std::collections::HashMap; use openmaths::Vector3; @@ -104,11 +105,13 @@ pub enum Segment2D { } /// One classified output segment from the HLR projection. +/// Uses camelCase for JSON serialization to match JavaScript/TypeScript API. #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ClassifiedSegment { pub geometry: Segment2D, pub class: EdgeClass, - /// AIA/NCS layer code (e.g. "A-WALL"). Populated by OGEntityRegistry in Phase 2. + /// AIA/NCS layer code (e.g. "A-WALL"). Populated by OGEntityRegistry. pub layer: Option, /// BRep UUID of the originating entity. pub source_entity_id: Option, @@ -144,6 +147,7 @@ impl Path2D { } #[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Scene2D { pub name: Option, pub segments: Vec, @@ -151,6 +155,7 @@ pub struct Scene2D { /// Flat line representation used by the existing WASM `projectTo2DLines` API. #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Line2D { pub start: Vec2, pub end: Vec2, @@ -162,6 +167,7 @@ pub struct Line2D { } #[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Scene2DLines { pub name: Option, pub lines: Vec, diff --git a/main/opengeometry/src/scenegraph.rs b/main/opengeometry/src/scenegraph.rs index 4b60bfa..0af965a 100644 --- a/main/opengeometry/src/scenegraph.rs +++ b/main/opengeometry/src/scenegraph.rs @@ -1,3 +1,4 @@ +// main/opengeometry/src/scenegraph.rs use std::collections::HashMap; use serde::{Deserialize, Serialize}; @@ -37,6 +38,9 @@ use crate::export::step::export_breps_to_step_file; #[cfg(not(target_arch = "wasm32"))] use crate::export::stl::export_breps_to_stl_file; +// Включаем сгенерированный код AIA слоев +include!(concat!(env!("OUT_DIR"), "/aia_layers.rs")); + #[derive(Clone, Serialize, Deserialize)] pub struct SceneEntity { pub id: String, @@ -1229,21 +1233,6 @@ struct ViewRequest { section_plane: Option, } -/// Map `OGEntityKind` strings to AIA/NCS layer codes (AIA CAD Layer Guidelines). -fn aia_layer(kind: &str) -> Option<&'static str> { - match kind.to_lowercase().trim() { - "wall" => Some("A-WALL"), - "door" => Some("A-DOOR"), - "window" | "glazing" | "glaz" => Some("A-GLAZ"), - "slab" | "floor" | "ceiling" => Some("A-FLOR"), - "stair" | "stairs" => Some("A-FLOR-STRS"), - "column" | "col" => Some("A-COLS"), - "beam" => Some("S-BEAM"), - "roof" => Some("A-ROOF"), - _ => None, - } -} - /// Flat entity registry with a batched multi-view projection API. /// Replaces the scene-centric OGSceneManager pattern for export use cases. #[wasm_bindgen] @@ -1262,20 +1251,89 @@ impl Default for OGEntityRegistry { impl OGEntityRegistry { fn project_view(&self, view: &ViewRequest) -> Scene2D { let mut out = Scene2D::with_name(view.id.clone()); + for entity in self.entities.values() { let mut entity_scene = project_brep_to_scene(&entity.brep, &view.camera, &view.hlr); - let layer = aia_layer(&entity.kind).map(str::to_string); + + let layer = aia_layer(&entity.kind).map(str::to_owned); + for seg in &mut entity_scene.segments { - seg.layer = layer.clone(); + seg.layer.clone_from(&layer); seg.source_entity_id = Some(entity.id.clone()); } out.extend(entity_scene); } - // Section-plane intersection is wired up here in Phase 3+. - // `view.section_plane` field is available for future use. + + // TODO: apply clipping against section plane once section projection is implemented. let _ = &view.section_plane; out } + + /// Shared helper for both native and WASM projection entrypoints. + fn project_views_json(&self, views_json: &str) -> Result { + let views: Vec = serde_json::from_str(views_json) + .map_err(|e| format!("Invalid views JSON: {}", e))?; + + let mut seen_ids = std::collections::HashSet::with_capacity(views.len()); + for view in &views { + if !seen_ids.insert(&view.id) { + return Err(format!("Duplicate view ID: '{}'", view.id)); + } + } + + let mut result: HashMap = HashMap::with_capacity(views.len()); + for view in &views { + result.insert(view.id.clone(), self.project_view(view)); + } + + serde_json::to_string(&result) + .map_err(|e| format!("Failed to serialize projection result: {}", e)) + } + + /// Внутренняя версия без wasm-bindgen для тестирования на native платформах + #[cfg(not(target_arch = "wasm32"))] + pub fn project_current_to_views_internal(&self, views_json: String) -> Result { + self.project_views_json(&views_json) + } + + /// Внутренняя версия register_entity без wasm-bindgen для тестирования на native + #[cfg(not(target_arch = "wasm32"))] + pub fn register_entity_internal( + &mut self, + id: String, + kind: String, + brep_json: String, + ) -> Result<(), String> { + let normalized_kind = kind.to_ascii_lowercase(); + + if normalized_kind.trim().is_empty() { + return Err("Entity kind cannot be empty".to_string()); + } + + if normalized_kind.len() > 64 { + return Err(format!( + "Entity kind too long (max 64 chars): {}", + normalized_kind.len() + )); + } + + if !normalized_kind.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + return Err(format!( + "Entity kind contains invalid characters: '{}'. Only alphanumeric, '_', and '-' are allowed.", + kind + )); + } + + let brep: Brep = serde_json::from_str(&brep_json) + .map_err(|e| format!("Failed to deserialize BRep: {}", e))?; + + brep.validate_topology() + .map_err(|e| format!("Invalid BRep topology for '{}': {}", id, e))?; + + self.entities + .insert(id.clone(), SceneEntity { id, kind: normalized_kind, brep }); + Ok(()) + } } #[wasm_bindgen] @@ -1285,7 +1343,7 @@ impl OGEntityRegistry { Self::default() } - /// Register (or replace) an entity. `kind` must be an OGEntityKind string. + /// Register (or replace) an entity. Kind is normalized to lowercase. #[wasm_bindgen(js_name = registerEntity)] pub fn register_entity( &mut self, @@ -1293,6 +1351,26 @@ impl OGEntityRegistry { kind: String, brep_json: String, ) -> Result<(), JsValue> { + let normalized_kind = kind.to_ascii_lowercase(); + + if normalized_kind.trim().is_empty() { + return Err(JsValue::from_str("Entity kind cannot be empty")); + } + + if normalized_kind.len() > 64 { + return Err(JsValue::from_str(&format!( + "Entity kind too long (max 64 chars): {}", + normalized_kind.len() + ))); + } + + if !normalized_kind.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + return Err(JsValue::from_str(&format!( + "Entity kind contains invalid characters: '{}'. Only alphanumeric, '_', and '-' are allowed.", + kind + ))); + } + let brep: Brep = serde_json::from_str(&brep_json) .map_err(|e| JsValue::from_str(&format!("Failed to deserialize BRep: {}", e)))?; @@ -1301,7 +1379,7 @@ impl OGEntityRegistry { })?; self.entities - .insert(id.clone(), SceneEntity { id, kind, brep }); + .insert(id.clone(), SceneEntity { id, kind: normalized_kind, brep }); Ok(()) } @@ -1318,28 +1396,15 @@ impl OGEntityRegistry { } /// Batched multi-view projection. - /// - /// Input: JSON array of `{ id, camera, hlr?, section_plane? }` (ViewRequest). - /// Output: JSON map of `{ viewportId: Scene2D }`. - /// - /// All viewports are projected in a single WASM call, amortising - /// serialisation overhead and keeping BRep data in WASM memory. #[wasm_bindgen(js_name = projectCurrentToViews)] pub fn project_current_to_views(&self, views_json: String) -> Result { - let views: Vec = serde_json::from_str(&views_json) - .map_err(|e| JsValue::from_str(&format!("Invalid views JSON: {}", e)))?; - - let mut result: HashMap = HashMap::with_capacity(views.len()); - for view in &views { - result.insert(view.id.clone(), self.project_view(view)); - } - - serde_json::to_string(&result).map_err(|e| { - JsValue::from_str(&format!("Failed to serialize projection result: {}", e)) - }) + self.project_views_json(&views_json) + .map_err(|e| JsValue::from_str(&e)) } } +// --------------------------------------------------------------------------- +// Existing tests (unchanged) // --------------------------------------------------------------------------- #[cfg(test)] @@ -1713,13 +1778,37 @@ mod tests { } #[test] - fn aia_layer_maps_known_kinds() { - assert_eq!(aia_layer("wall"), Some("A-WALL")); - assert_eq!(aia_layer("door"), Some("A-DOOR")); - assert_eq!(aia_layer("window"), Some("A-GLAZ")); - assert_eq!(aia_layer("slab"), Some("A-FLOR")); - assert_eq!(aia_layer("stair"), Some("A-FLOR-STRS")); - assert_eq!(aia_layer("column"), Some("A-COLS")); - assert_eq!(aia_layer("unknown"), None); + fn registry_duplicate_view_ids_rejected() { + #[cfg(not(target_arch = "wasm32"))] + { + let registry = OGEntityRegistry::new(); + let camera = CameraParameters::default(); + let camera_json = serde_json::to_string(&camera).unwrap(); + let views_json = format!( + r#"[{{"id":"plan","camera":{cam}}},{{"id":"plan","camera":{cam}}}]"#, + cam = camera_json + ); + + let result = registry.project_current_to_views_internal(views_json); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("Duplicate view ID")); + } + + #[cfg(target_arch = "wasm32")] + { + let registry = OGEntityRegistry::new(); + let camera_json = serde_json::to_string(&CameraParameters::default()).unwrap(); + let views_json = format!( + r#"[{{"id":"plan","camera":{cam}}},{{"id":"plan","camera":{cam}}}]"#, + cam = camera_json + ); + + let result = registry.project_current_to_views(views_json); + assert!(result.is_err()); + let err = result.unwrap_err(); + let err_str = err.as_string().unwrap(); + assert!(err_str.contains("Duplicate view ID")); + } } } diff --git a/main/opengeometry/tests/registry_tests.rs b/main/opengeometry/tests/registry_tests.rs new file mode 100644 index 0000000..fe102e7 --- /dev/null +++ b/main/opengeometry/tests/registry_tests.rs @@ -0,0 +1,450 @@ +// main/opengeometry/tests/registry_tests.rs + +use opengeometry::scenegraph::OGEntityRegistry; +use opengeometry::export::projection::{CameraParameters, ProjectionMode}; +use opengeometry::brep::{Brep, BrepBuilder}; +use opengeometry::primitives::cuboid::OGCuboid; +use openmaths::Vector3; +use uuid::Uuid; +use serde_json; +use std::collections::HashMap; + +/// Создает тестовый BRep в виде треугольника +fn create_triangle_brep() -> Brep { + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&[ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(2.0, 0.0, 0.0), + Vector3::new(1.0, 2.0, 0.0), + ]); + builder.add_face(&[0, 1, 2], &[]).unwrap(); + builder.build().unwrap() +} + +/// Создает куб с использованием штатного примитива OGCuboid +fn create_cube_brep(size: f64) -> Brep { + let mut cuboid = OGCuboid::new("test_cuboid".to_string()); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.0), size, size, size) + .expect("Failed to set cuboid config"); + cuboid.world_brep() +} + +/// Создает простой BRep в виде линии +fn create_line_brep() -> Brep { + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&[ + Vector3::new(-1.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ]); + builder.add_wire(&[0, 1], false).unwrap(); + builder.build().unwrap() +} + +#[test] +fn test_registry_register_and_replace() { + let mut registry = OGEntityRegistry::new(); + let brep = create_triangle_brep(); + let brep_json = serde_json::to_string(&brep).unwrap(); + + registry.register_entity("test-1".to_string(), "wall".to_string(), brep_json.clone()) + .expect("Should register entity"); + + registry.register_entity("test-1".to_string(), "wall".to_string(), brep_json.clone()) + .expect("Should replace entity"); +} + +#[test] +fn test_registry_unregister() { + let mut registry = OGEntityRegistry::new(); + let brep = create_triangle_brep(); + let brep_json = serde_json::to_string(&brep).unwrap(); + + registry.register_entity("test-1".to_string(), "wall".to_string(), brep_json) + .unwrap(); + + let removed = registry.unregister_entity("test-1".to_string()); + assert!(removed); + + let removed2 = registry.unregister_entity("nonexistent".to_string()); + assert!(!removed2); +} + +#[test] +fn test_registry_clear() { + let mut registry = OGEntityRegistry::new(); + let brep = create_triangle_brep(); + let brep_json = serde_json::to_string(&brep).unwrap(); + + registry.register_entity("e1".to_string(), "wall".to_string(), brep_json.clone()).unwrap(); + registry.register_entity("e2".to_string(), "door".to_string(), brep_json).unwrap(); + + assert!(registry.unregister_entity("e1".to_string())); + assert!(!registry.unregister_entity("e1".to_string())); + + registry.clear_entities(); + assert!(!registry.unregister_entity("e2".to_string())); +} + +#[test] +fn test_registry_multi_view_projection() { + let mut registry = OGEntityRegistry::new(); + let brep = create_cube_brep(2.0); + let brep_json = serde_json::to_string(&brep).unwrap(); + + registry.register_entity("cube".to_string(), "wall".to_string(), brep_json).unwrap(); + + let camera = CameraParameters::default(); + let camera_json = serde_json::to_string(&camera).unwrap(); + + let views_json = format!( + r#"[{{"id":"plan","camera":{cam}}},{{"id":"front","camera":{cam}}},{{"id":"iso","camera":{cam}}}]"#, + cam = camera_json + ); + + let result = registry.project_current_to_views(views_json).unwrap(); + let result_map: HashMap = + serde_json::from_str(&result).unwrap(); + + assert!(result_map.contains_key("plan")); + assert!(result_map.contains_key("front")); + assert!(result_map.contains_key("iso")); + assert_eq!(result_map.len(), 3); + + for view_id in &["plan", "front", "iso"] { + let segments = result_map[*view_id]["segments"].as_array().unwrap(); + assert!(!segments.is_empty(), "View '{}' should have segments", view_id); + } +} + +#[test] +fn test_registry_layer_attribution() { + let mut registry = OGEntityRegistry::new(); + let brep = create_cube_brep(1.0); + let brep_json = serde_json::to_string(&brep).unwrap(); + + let test_cases = vec![ + ("wall", "A-WALL"), + ("door", "A-DOOR"), + ("window", "A-GLAZ"), + ("floor", "A-FLOR"), + ("roof", "A-ROOF"), + ("column", "A-COLS"), + ("structural_beam", "S-BEAM"), + ("duct", "M-DUCT"), + ("pipe", "P-PIPE"), + ("site", "G-SITE"), + ("existing", "A-EXST"), + ]; + + let camera = CameraParameters::default(); + let camera_json = serde_json::to_string(&camera).unwrap(); + + for (kind, expected_layer) in test_cases { + let id = format!("test-{}", kind); + registry.register_entity(id.clone(), kind.to_string(), brep_json.clone()).unwrap(); + + let views_json = format!(r#"[{{"id":"plan","camera":{}}}]"#, camera_json); + let result = registry.project_current_to_views(views_json).unwrap(); + let result_map: HashMap = + serde_json::from_str(&result).unwrap(); + + let segments = result_map["plan"]["segments"].as_array().unwrap(); + + for seg in segments { + assert!( + seg["sourceEntityId"].is_string(), + "sourceEntityId should be present in segment: {}", + seg + ); + } + + let found_correct_layer = segments + .iter() + .any(|seg| seg["layer"].as_str() == Some(expected_layer)); + + assert!( + found_correct_layer, + "No segment found with layer '{}' for kind '{}'", + expected_layer, + kind + ); + + registry.unregister_entity(id); + } +} + +#[test] +fn test_registry_source_entity_id_preserved() { + let mut registry = OGEntityRegistry::new(); + + // Используем куб и линию + let cube_brep = create_cube_brep(1.0); + let line_brep = create_line_brep(); + + registry.register_entity( + "cube-1".to_string(), + "wall".to_string(), + serde_json::to_string(&cube_brep).unwrap(), + ).unwrap(); + + registry.register_entity( + "line-1".to_string(), + "door".to_string(), + serde_json::to_string(&line_brep).unwrap(), + ).unwrap(); + + // Используем камеру, которая покажет оба объекта + let camera = CameraParameters { + position: Vector3::new(0.0, 0.0, 5.0), + target: Vector3::new(0.0, 0.0, 0.0), + up: Vector3::new(0.0, 1.0, 0.0), + near: 0.01, + projection_mode: ProjectionMode::Orthographic, + }; + let camera_json = serde_json::to_string(&camera).unwrap(); + let views_json = format!(r#"[{{"id":"plan","camera":{}}}]"#, camera_json); + + let result = registry.project_current_to_views(views_json).unwrap(); + let result_map: HashMap = + serde_json::from_str(&result).unwrap(); + + let segments = result_map["plan"]["segments"].as_array().unwrap(); + + let mut found_cube = false; + let mut found_line = false; + + for seg in segments { + let source_id = seg["sourceEntityId"].as_str() + .expect("sourceEntityId should be present"); + + if source_id == "cube-1" { + found_cube = true; + } else if source_id == "line-1" { + found_line = true; + } + } + + assert!(found_cube, "Should have segments from cube-1"); + assert!(found_line, "Should have segments from line-1"); +} + +#[test] +fn test_registry_entity_update() { + let mut registry = OGEntityRegistry::new(); + + let small_cube = create_cube_brep(1.0); + let small_json = serde_json::to_string(&small_cube).unwrap(); + registry.register_entity( + "cube".to_string(), + "wall".to_string(), + small_json, + ).unwrap(); + + let camera = CameraParameters::default(); + let camera_json = serde_json::to_string(&camera).unwrap(); + let views_json = format!(r#"[{{"id":"plan","camera":{}}}]"#, camera_json); + + let result_before = registry.project_current_to_views(views_json.clone()).unwrap(); + let map_before: HashMap = + serde_json::from_str(&result_before).unwrap(); + let segments_before = map_before["plan"]["segments"].as_array().unwrap().len(); + assert!(segments_before > 0, "Should have segments before update"); + + let large_cube = create_cube_brep(5.0); + let large_json = serde_json::to_string(&large_cube).unwrap(); + registry.register_entity( + "cube".to_string(), + "wall".to_string(), + large_json, + ).unwrap(); + + let result_after = registry.project_current_to_views(views_json).unwrap(); + let map_after: HashMap = + serde_json::from_str(&result_after).unwrap(); + let segments_after = map_after["plan"]["segments"].as_array().unwrap().len(); + assert!(segments_after > 0, "Should have segments after update"); + + let source_ids: Vec = map_after["plan"]["segments"] + .as_array() + .unwrap() + .iter() + .filter_map(|seg| seg["sourceEntityId"].as_str().map(String::from)) + .collect(); + + assert!(!source_ids.is_empty(), "sourceEntityId should be preserved"); + assert_eq!(source_ids[0], "cube", "Entity ID should be 'cube'"); +} + +#[test] +fn test_registry_replace_entity() { + let mut registry = OGEntityRegistry::new(); + + // Регистрируем куб как стену + let wall_brep = create_cube_brep(1.0); + let wall_json = serde_json::to_string(&wall_brep).unwrap(); + registry.register_entity( + "test-1".to_string(), + "wall".to_string(), + wall_json, + ).unwrap(); + + let camera = CameraParameters::default(); + let camera_json = serde_json::to_string(&camera).unwrap(); + let views_json = format!(r#"[{{"id":"plan","camera":{}}}]"#, camera_json); + + let result = registry.project_current_to_views(views_json.clone()).unwrap(); + let result_map: HashMap = + serde_json::from_str(&result).unwrap(); + let segments = result_map["plan"]["segments"].as_array().unwrap(); + + // Проверяем, что есть сегменты со слоем A-WALL + let found_wall = segments + .iter() + .any(|seg| seg["layer"].as_str() == Some("A-WALL")); + assert!(found_wall, "Should have A-WALL layer before replacement"); + + // Заменяем на линию как дверь + let door_brep = create_line_brep(); + let door_json = serde_json::to_string(&door_brep).unwrap(); + registry.register_entity( + "test-1".to_string(), + "door".to_string(), + door_json, + ).unwrap(); + + let result2 = registry.project_current_to_views(views_json).unwrap(); + let result_map2: HashMap = + serde_json::from_str(&result2).unwrap(); + let segments2 = result_map2["plan"]["segments"].as_array().unwrap(); + + // Проверяем, что есть сегменты со слоем A-DOOR + let found_door = segments2 + .iter() + .any(|seg| seg["layer"].as_str() == Some("A-DOOR")); + assert!(found_door, "Should have A-DOOR layer after replacement"); +} + +// Этот тест использует внутренний метод без wasm-bindgen на native +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_registry_invalid_kind_rejected_native() { + let mut registry = OGEntityRegistry::new(); + let brep = create_triangle_brep(); + let brep_json = serde_json::to_string(&brep).unwrap(); + + // Пустой kind + let result = registry.register_entity_internal( + "test".to_string(), + "".to_string(), + brep_json.clone(), + ); + assert!(result.is_err(), "Empty kind should be rejected"); + assert!(result.unwrap_err().contains("cannot be empty")); + + // Пробелы + let result = registry.register_entity_internal( + "test".to_string(), + " ".to_string(), + brep_json.clone(), + ); + assert!(result.is_err(), "Whitespace kind should be rejected"); + assert!(result.unwrap_err().contains("cannot be empty")); + + // Специальные символы + let result = registry.register_entity_internal( + "test".to_string(), + "wall!@#".to_string(), + brep_json.clone(), + ); + assert!(result.is_err(), "Kind with special chars should be rejected"); + assert!(result.unwrap_err().contains("invalid characters")); + + // Слишком длинный + let long_kind = "a".repeat(65); + let result = registry.register_entity_internal( + "test".to_string(), + long_kind, + brep_json.clone(), + ); + assert!(result.is_err(), "Too long kind should be rejected"); + assert!(result.unwrap_err().contains("too long")); + + // Валидный kind должен пройти + let result = registry.register_entity_internal( + "test".to_string(), + "structural_beam-123".to_string(), + brep_json, + ); + assert!(result.is_ok(), "Valid kind should be accepted"); +} + +// Этот тест использует wasm-bindgen функции, поэтому пропускаем на native +#[test] +#[cfg_attr(not(target_arch = "wasm32"), ignore = "requires wasm-bindgen")] +fn test_registry_invalid_brep_rejected() { + let mut registry = OGEntityRegistry::new(); + + let result = registry.register_entity( + "test".to_string(), + "wall".to_string(), + "invalid json".to_string(), + ); + assert!(result.is_err()); + + let empty_brep = Brep::new(Uuid::new_v4()); + let empty_json = serde_json::to_string(&empty_brep).unwrap(); + let result = registry.register_entity( + "test".to_string(), + "wall".to_string(), + empty_json, + ); + assert!(result.is_err()); +} + +// Этот тест использует wasm-bindgen функции, поэтому пропускаем на native +#[test] +#[cfg_attr(not(target_arch = "wasm32"), ignore = "requires wasm-bindgen")] +fn test_registry_aia_layer_no_duplicates() { + let known_types = vec![ + "wall", "walls", "partition", + "door", "doors", + "window", "windows", "glazing", "glaz", "glass", + "floor", "floors", "slab", "slabs", + "column", "columns", "col", "pillar", + "beam", "beams", + "girder", "girders", + "joist", "joists", + ]; + + for kind in known_types { + let mut registry = OGEntityRegistry::new(); + let brep = create_cube_brep(1.0); + let brep_json = serde_json::to_string(&brep).unwrap(); + + let result = registry.register_entity( + format!("test-{}", kind), + kind.to_string(), + brep_json, + ); + assert!(result.is_ok(), "Kind '{}' should register successfully", kind); + } +} + +// Этот тест использует внутренний метод, доступный только на native +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_registry_duplicate_view_ids_rejected() { + let registry = OGEntityRegistry::new(); + let camera = CameraParameters::default(); + let camera_json = serde_json::to_string(&camera).unwrap(); + let views_json = format!( + r#"[{{"id":"plan","camera":{cam}}},{{"id":"plan","camera":{cam}}}]"#, + cam = camera_json + ); + + let result = registry.project_current_to_views_internal(views_json); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("Duplicate view ID")); +} diff --git a/main/openmaths/src/lib.rs b/main/openmaths/src/lib.rs new file mode 100644 index 0000000..445c5a3 --- /dev/null +++ b/main/openmaths/src/lib.rs @@ -0,0 +1,83 @@ +// main/openmaths/src/lib.rs +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] +pub struct Vector3 { + pub x: f64, + pub y: f64, + pub z: f64, +} + +impl Vector3 { + pub fn new(x: f64, y: f64, z: f64) -> Self { + Self { x, y, z } + } + + pub fn zero() -> Self { + Self::new(0.0, 0.0, 0.0) + } + + pub fn dot(&self, other: &Vector3) -> f64 { + self.x * other.x + self.y * other.y + self.z * other.z + } + + pub fn cross(&self, other: &Vector3) -> Vector3 { + Vector3::new( + self.y * other.z - self.z * other.y, + self.z * other.x - self.x * other.z, + self.x * other.y - self.y * other.x, + ) + } + + pub fn length(&self) -> f64 { + (self.x * self.x + self.y * self.y + self.z * self.z).sqrt() + } + + pub fn normalize(&self) -> Option { + let len = self.length(); + if len < 1e-10 { + None + } else { + Some(Vector3::new(self.x / len, self.y / len, self.z / len)) + } + } + + pub fn apply_matrix4(&mut self, matrix: [[f64; 4]; 4]) { + let x = self.x; + let y = self.y; + let z = self.z; + self.x = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z + matrix[0][3]; + self.y = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z + matrix[1][3]; + self.z = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z + matrix[2][3]; + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct Matrix4 { + pub m: [[f64; 4]; 4], +} + +impl Matrix4 { + pub fn identity() -> Self { + Self { + m: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + } + } + + pub fn multiply(&self, other: &Matrix4) -> Matrix4 { + let mut result = [[0.0; 4]; 4]; + for i in 0..4 { + for j in 0..4 { + for k in 0..4 { + result[i][j] += self.m[i][k] * other.m[k][j]; + } + } + } + Matrix4 { m: result } + } +} diff --git a/package-lock.json b/package-lock.json index 7a42f83..de413cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,14 @@ { "name": "opengeometry", - "version": "2.0.9", + "version": "2.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opengeometry", - "version": "2.0.9", + "version": "2.0.11", "license": "MPL-2.0", "dependencies": { - "tsc": "^2.0.4", "uuid": "^10.0.0" }, "devDependencies": { @@ -3608,14 +3607,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tsc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/tsc/-/tsc-2.0.4.tgz", - "integrity": "sha512-fzoSieZI5KKJVBYGvwbVZs/J5za84f2lSTLPYf6AGiIf43tZ3GNrI1QzTLcjtyDDP4aLxd46RTZq1nQxe7+k5Q==", - "bin": { - "tsc": "bin/tsc" - } - }, "node_modules/tslib": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", diff --git a/package.json b/package.json index 18c6b8d..15629d6 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,20 @@ "type": "module", "main": "index.js", "scripts": { + "generate:layers": "tsx scripts/generate-layers.ts", + "prebuild": "npm run generate:layers", "dev": "vite", - "test": "cargo test --manifest-path main/opengeometry/Cargo.toml && cargo test --examples --manifest-path main/opengeometry/Cargo.toml", + "test": "npm run test:rust && npm run test:ts", + "test:rust": "cargo test --manifest-path main/opengeometry/Cargo.toml", + "test:rust:verbose": "cargo test --manifest-path main/opengeometry/Cargo.toml -- --nocapture", + "test:rust:registry": "cargo test --manifest-path main/opengeometry/Cargo.toml registry_", + "test:rust:scene": "cargo test --manifest-path main/opengeometry/Cargo.toml test_scene_", + "test:ts": "vitest", + "test:ts:watch": "vitest --watch", + "test:ts:ui": "vitest --ui", + "test:ts:coverage": "vitest --coverage", + "bench": "cargo bench --manifest-path main/opengeometry/Cargo.toml", + "bench:registry": "cargo bench --manifest-path main/opengeometry/Cargo.toml -- registry_benchmark", "lint": "eslint main/opengeometry-three/src/**/*.ts --fix", "lint:check": "eslint main/opengeometry-three/src/**/*.ts", "build-three": "rollup -c rollup.config.js", @@ -16,7 +28,10 @@ "build-core": "cd main/opengeometry && wasm-pack build --target web && cargo build --release", "prepare-dist": "node ./scripts/prepare-dist.mjs", "copy-wasm": "npm run prepare-dist", - "build": "npm run build-core && npm run build-three && npm run prepare-dist" + "build": "npm run build-core && npm run build-three && npm run prepare-dist", + "clean": "cargo clean --manifest-path main/opengeometry/Cargo.toml && rm -rf node_modules dist coverage", + "clean:rust": "cargo clean --manifest-path main/opengeometry/Cargo.toml", + "clean:ts": "rm -rf node_modules dist coverage" }, "repository": { "type": "git", @@ -59,9 +74,14 @@ "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", + "@vitest/ui": "^2.0.0", "eslint": "^9.35.0", "rollup": "^4.21.3", "tslib": "^2.7.0", - "vite": "^6.2.2" + "tsx": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^6.2.2", + "vitest": "^2.0.0", + "yaml": "^2.4.0" } } diff --git a/scripts/generate-layers.ts b/scripts/generate-layers.ts new file mode 100644 index 0000000..5acea6a --- /dev/null +++ b/scripts/generate-layers.ts @@ -0,0 +1,70 @@ +// scripts/generate-layers.ts +import * as fs from 'fs'; +import * as yaml from 'yaml'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface LayerMapping { + layers: Record; +} + +function generateTypeScript() { + const yamlPath = path.join(__dirname, '..', 'layers.yaml'); + const yamlContent = fs.readFileSync(yamlPath, 'utf-8'); + const mapping: LayerMapping = yaml.parse(yamlContent); + + let output = '// Auto-generated from layers.yaml\n'; + output += '// DO NOT EDIT MANUALLY\n\n'; + + // Генерируем enum + output += 'export enum AIALayer {\n'; + const sortedLayers = Object.keys(mapping.layers).sort(); + for (const layer of sortedLayers) { + const key = layer.replace(/-/g, '_').toUpperCase(); + output += ` ${key} = '${layer}',\n`; + } + output += '}\n\n'; + + // Генерируем маппинг + output += 'export const ENTITY_TO_LAYER: Record = {\n'; + const entries: string[] = []; + for (const [layer, kinds] of Object.entries(mapping.layers)) { + const enumKey = layer.replace(/-/g, '_').toUpperCase(); + for (const kind of kinds) { + entries.push(` '${kind.toLowerCase()}': AIALayer.${enumKey},`); + } + } + entries.sort(); + output += entries.join('\n'); + output += '\n};\n\n'; + + output += 'export function getLayerForEntity(kind: string): AIALayer | null {\n'; + output += ' const normalized = kind.toLowerCase().trim();\n'; + output += ' return ENTITY_TO_LAYER[normalized] || null;\n'; + output += '}\n'; + + const outPath = path.join( + __dirname, + '..', + 'main/opengeometry-three/src/registry/layers.ts' + ); + + // Создаем директорию если не существует + const dir = path.dirname(outPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(outPath, output); + console.log(`✅ Generated ${outPath}`); +} + +try { + generateTypeScript(); +} catch (error) { + console.error('❌ Failed to generate layers:', error); + process.exit(1); +}