From d82b11159c04ca9e5dc87301bb562a7963dcae4c Mon Sep 17 00:00:00 2001 From: mie Date: Wed, 11 Nov 2026 03:23:38 +0300 Subject: [PATCH] feat: add 4D construction sequencing, 5D cost planning, spatial zones, BCF archive reader, and IfcApi facade --- Directory.Build.props | 4 +- README.md | 90 ++- docs/getting-started.md | 65 ++ docs/public-api.md | 24 + src/IfcIO.Cli/Program.cs | 210 +++++++ src/IfcIO/Api/IfcApi.cs | 206 +++++++ src/IfcIO/Bcf/BcfArchive.cs | 74 +++ src/IfcIO/Bcf/BcfTypes.cs | 4 + src/IfcIO/Bcf/BcfZipExporter.cs | 87 ++- src/IfcIO/Core/StepWriter.cs | 4 + src/IfcIO/Exporters/IfcHtmlViewerExporter.cs | 34 + src/IfcIO/IfcModel.cs | 613 ++++++++++++++++++- src/IfcIO/Model/Cost/IfcCostTypes.cs | 221 +++++++ src/IfcIO/Model/IfcModelTypes.cs | 54 ++ src/IfcIO/Model/Sequence/IfcSequenceTypes.cs | 358 +++++++++++ tests/IfcIO.Tests/BcfArchiveTests.cs | 65 ++ tests/IfcIO.Tests/CliExtendedTests.cs | 97 +++ tests/IfcIO.Tests/CostTests.cs | 70 +++ tests/IfcIO.Tests/IfcApiTests.cs | 52 ++ tests/IfcIO.Tests/SequenceTests.cs | 115 ++++ tests/IfcIO.Tests/SpatialZoneTests.cs | 50 ++ 21 files changed, 2484 insertions(+), 13 deletions(-) create mode 100644 src/IfcIO/Api/IfcApi.cs create mode 100644 src/IfcIO/Bcf/BcfArchive.cs create mode 100644 src/IfcIO/Model/Cost/IfcCostTypes.cs create mode 100644 src/IfcIO/Model/Sequence/IfcSequenceTypes.cs create mode 100644 tests/IfcIO.Tests/BcfArchiveTests.cs create mode 100644 tests/IfcIO.Tests/CliExtendedTests.cs create mode 100644 tests/IfcIO.Tests/CostTests.cs create mode 100644 tests/IfcIO.Tests/IfcApiTests.cs create mode 100644 tests/IfcIO.Tests/SequenceTests.cs create mode 100644 tests/IfcIO.Tests/SpatialZoneTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 21fdecd..a09d7fb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,7 @@ - 0.9.0 + 0.10.0 mahach666 Copyright (c) 2026 IOExtend IfcIO @@ -24,7 +24,7 @@ https://github.com/IOExtend/IfcIO https://github.com/IOExtend/IfcIO git - ifc;ifc2x3;ifc4;step;iso-10303-21;bim;cad;brep;tessellation;glb;gltf;obj;ifczip;bcf;bcfzip;csg;mep;ids;ifcpatch;ifcselector;ifccsv;clash;diff;viewer;webgl;html-viewer;grids;measure;clash-grouping;buildingsmart + ifc;ifc2x3;ifc4;step;iso-10303-21;bim;cad;brep;tessellation;glb;gltf;obj;ifczip;bcf;bcfzip;sequence;4d;cost;5d;zones;csg;mep;ids;ifcpatch;ifcselector;ifccsv;clash;diff;viewer;webgl;html-viewer;grids;measure;clash-grouping;buildingsmart README.md icon.png diff --git a/README.md b/README.md index d43fa49..2afe730 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,23 @@ Built clean-room under the [IOExtend](https://github.com/IOExtend) organization - Full revision comparison between two IFC models detecting added, deleted, and modified elements (3D geometry displacement, property set deltas, quantity variations, and storey re-assignments) conforming to `ifcdiff`. Direct export to JSON and BCF 2.1 archives. - **Standalone 3D WebGL HTML Viewer (`IfcHtmlViewerExporter`)**: - Export any IFC model to an interactive, self-contained single-file 3D HTML viewer (`.html`) with zero external scripts, CDNs, or server dependencies. - - Includes orbit camera controls, live multi-axis section clipping planes (Cut X, Cut Y, Cut Z), spatial tree navigation, element property inspector, interactive 2-point 3D measurement tool, and 2D canvas overlay drawing of architectural grid axes and bubble labels. + - Includes orbit camera controls, live multi-axis section clipping planes (Cut X, Cut Y, Cut Z), spatial tree navigation, element property inspector, interactive 2-point 3D measurement tool, 2D canvas overlay drawing of architectural grid axes and bubble labels, and interactive 4D timeline simulation playback. +- **4D Construction Sequencing & Temporal Simulation (`IfcWorkSchedule`, `IfcTask`, `IfcTaskTime`, `IFCRELSEQUENCE`)**: + - Full support for construction work schedules, WBS hierarchies, task durations, milestones, and predecessor/successor relationships (`FinishToStart`, `StartToStart`, `FinishToFinish`, `StartToFinish`). + - Temporal 4D simulation engine: query active tasks, completed tasks, overall progress ratio, and element states (`Planned`, `InProgress`, `Completed`, `NotStarted`) at any given simulation date. + - Interactive 4D timeline playback in the standalone HTML viewer (`ifcio view`). +- **5D Cost Planning & Budgeting (`IfcCostSchedule`, `IfcCostItem`, `IfcCostValue`, `IFCAPPLIEDVALUE`)**: + - Hierarchical Cost Breakdown Structure (CBS), unit rates, quantities, and automatic cost rollup across tree nodes. + - Elements and physical quantities linked to cost items via `IFCRELASSIGNSTOCONTROL`. + - CLI command `ifcio cost` with CSV export. +- **Spatial Zones (`IfcZone`, `IFCZONE`)**: + - Grouping architectural spaces and rooms into thermal, fire safety, acoustic, security, and ventilation zones. +- **Bi-directional BCF 2.1 Archive Container (`BcfArchive`)**: + - Full in-memory or stream-based container for reading, inspecting, modifying, and writing buildingSMART BCF 2.1 zip archives (`.bcfzip`). + - High-level queries by element GUID (`FindTopicsByElement`), status (`FindTopicsByStatus`), and snapshot image attachments. + - CLI command `ifcio bcf` for inspection and topic listing. +- **High-Level IfcOpenShell-Compatible API Facade (`IfcApi`)**: + - Pythonic, clean API: `IfcApi.Sequence`, `IfcApi.Cost`, `IfcApi.Spatial`, and `IfcApi.Root` for rapid automation. - **Zero Dependencies**: Pure C# targeting `.NET 8` and `.NET 10`. --- @@ -470,6 +486,69 @@ model.SaveHtmlViewer("BuildingModel.html", new IfcHtmlViewerOptions }); ``` +### 4D Construction Sequencing & Simulation + +```csharp +using IfcIO.Model.Sequence; + +// Create 4D schedule with tasks, WBS, and element assignments +var schedule = model.AddWorkSchedule("Tower Schedule"); +var task = schedule.AddTask("Construct Walls", "WBS-1.1", new DateTime(2026, 2, 1), new DateTime(2026, 3, 1)); +task.AssignElement(wall); + +// Query temporal state during construction +var activeTasks = schedule.GetActiveTasksAt(new DateTime(2026, 2, 15)); +var state = schedule.GetElementStateAt(wall, new DateTime(2026, 2, 15)); // => IfcElementSimulationState.InProgress +``` + +### 5D Cost Planning & CBS Budgeting + +```csharp +using IfcIO.Model.Cost; + +// Create 5D cost schedule with CBS items, unit rates, and quantities +var budget = model.AddCostSchedule("Master Budget", "USD", IfcCostScheduleType.Budget); +var masonryItem = budget.AddCostItem("Wall Masonry", "04.20.00", unitRate: 150.0, quantity: 200.0, unitBasis: "m2"); +masonryItem.AssignElement(wall); + +Console.WriteLine($"Total Item Cost: {masonryItem.TotalCost} USD"); // => 30000 USD +Console.WriteLine($"Grand Budget: {budget.GetTotalCost()} USD"); +``` + +### High-Level IfcOpenShell-Compatible API Facade (`IfcApi`) + +```csharp +using IfcIO.Api; + +var model = IfcApi.Root.CreateProject("Commercial Office", schema: IfcIO.Schema.IfcSchema.Ifc4); +var storey = IfcApi.Root.CreateStorey(model, "Ground Floor", 0.0); +var wall = IfcApi.Root.CreateWall(model, storey, "Exterior Wall"); + +// High-level 4D and 5D workflows +var ws = IfcApi.Sequence.AddWorkSchedule(model, "Main Schedule"); +var task = IfcApi.Sequence.AddTask(model, ws, "Wall Framing", new DateTime(2026, 1, 10), new DateTime(2026, 2, 10)); +IfcApi.Sequence.AssignProcess(task, wall); + +var cs = IfcApi.Cost.AddCostSchedule(model, "Base Budget"); +var ci = IfcApi.Cost.AddCostItem(cs, "Framing Labor", unitRate: 75.0, quantity: 120.0); +IfcApi.Cost.AssignCostControl(ci, wall); +``` + +### buildingSMART BCF 2.1 Archive Container (`BcfArchive`) + +```csharp +using IfcIO.Bcf; + +// Read, query, and modify BCF zip archives directly +using var bcf = BcfArchive.Open("Issues.bcfzip"); +var activeTopics = bcf.FindTopicsByStatus("Active"); +var wallIssues = bcf.FindTopicsByElement(wall.GlobalId); + +// Add topic and export updated archive +bcf.AddTopic("Verify concrete cover thickness", "Architectural Review", priority: "High"); +bcf.Save("Issues_Updated.bcfzip"); +``` + --- ## Global CLI Tool (`ifcio`) @@ -486,6 +565,15 @@ dotnet tool install --global IfcIO.Cli # Inspect model metadata, storeys, spaces, systems, and element distribution ifcio info Building.ifc --report --spaces --systems --export-grids Grids.xml +# 4D construction schedule temporal simulation and CSV export +ifcio schedule Building.ifc --sim-date 2026-06-01 --export-csv ScheduleReport.csv + +# 5D cost breakdown and budget reporting +ifcio cost Building.ifc --export-csv CostBreakdown.csv + +# Inspect BCF 2.1 archive topics and viewpoints +ifcio bcf Issues.bcfzip + # Query elements matching IfcOpenShell-style selector ifcio query Building.ifc "IfcWall[LoadBearing=TRUE]" diff --git a/docs/getting-started.md b/docs/getting-started.md index 5e0874a..b1f2fbb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -83,6 +83,62 @@ IfcGlbExporter.Export(model, "Building.glb"); IfcObjExporter.Export(model, "Building.obj"); ``` +### 4D Construction Scheduling & Timeline Simulation + +```csharp +using IfcIO; +using IfcIO.Model.Sequence; + +var model = IfcModel.Open("Project.ifc"); + +// Access work schedules and tasks +var schedule = model.WorkSchedules.FirstOrDefault(); +if (schedule is not null) +{ + Console.WriteLine($"Schedule: {schedule.Name}"); + DateTime simDate = DateTime.UtcNow; + + // Temporal 4D simulation + var activeTasks = schedule.GetActiveTasksAt(simDate); + double progress = schedule.GetProgressAt(simDate); + Console.WriteLine($"Progress at {simDate:d}: {progress:P1} ({activeTasks.Count} active tasks)"); +} +``` + +### 5D Cost Planning & CBS Budgeting + +```csharp +using IfcIO; +using IfcIO.Model.Cost; + +var model = IfcModel.Open("Project.ifc"); + +// Inspect 5D cost schedules +foreach (var costSchedule in model.CostSchedules) +{ + Console.WriteLine($"Cost Plan: {costSchedule.Name} - Grand Total: {costSchedule.GetTotalCost():N2} {costSchedule.Currency}"); + foreach (var item in costSchedule.AllCostItems) + { + Console.WriteLine($" - [{item.CostCode}] {item.Name}: {item.TotalCost:N2} {costSchedule.Currency}"); + } +} +``` + +### BCF 2.1 Archive Container + +```csharp +using IfcIO.Bcf; + +// Open a BCF archive directly from disk or stream +using var archive = BcfArchive.Open("Issues.bcfzip"); +Console.WriteLine($"Found {archive.Topics.Count} coordination topic(s):"); + +foreach (var topic in archive.Topics) +{ + Console.WriteLine($"[{topic.TopicStatus}] {topic.Title} (Priority: {topic.Priority})"); +} +``` + --- ## CLI Usage @@ -93,6 +149,15 @@ Run standard operations from your terminal: # Display summary info, schema version, and element counts ifcio info Building.ifc +# Run 4D construction schedule simulation at a specific date +ifcio schedule Building.ifc --sim-date 2026-06-01 --export-csv ScheduleReport.csv + +# Inspect 5D cost budgets and export tabular breakdown +ifcio cost Building.ifc --export-csv CostBreakdown.csv + +# Inspect BCF coordination topics +ifcio bcf Issues.bcfzip + # Convert IFC to binary glTF 2.0 and Wavefront OBJ ifcio export Building.ifc --glb scene.glb --obj scene.obj diff --git a/docs/public-api.md b/docs/public-api.md index ddb1076..52cb423 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -42,3 +42,27 @@ The `IfcIO` package is the supported surface listed here. | `IfcDiffEngine` | Revision comparison engine detecting added, deleted, and modified IFC elements and geometry shifts. | | `IfcValidator` | Structural STEP syntax and buildingSMART schema conformance validator. | | `IfcBcfExporter` | buildingSMART BIM Collaboration Format (BCF 2.1 `.bcfzip`) issue and viewpoint generator. | +| `BcfArchive` | Bidirectional BCF 2.1 archive container for reading, querying, editing, and saving `.bcfzip` files. | + +## 4D Construction Sequencing & Temporal Simulation + +| Type | Description | +|---|---| +| `IfcWorkSchedule` | Construction schedule container managing WBS hierarchies, baseline dates, temporal simulation, and progress tracking. | +| `IfcTask` | Individual construction activity or milestone with duration, planned/actual dates, and predecessor/successor links. | +| `IfcTaskTime` | Time entity conforming to `IFCTASKTIME` capturing early/late dates, durations, completion ratio, and milestones. | + +## 5D Cost Planning & CBS Budgeting + +| Type | Description | +|---|---| +| `IfcCostSchedule` | 5D budget, tender, estimate, or cost plan conforming to `IFCCOSTSCHEDULE` with currency and rollups. | +| `IfcCostItem` | Cost breakdown structure (CBS) node with unit rates, quantities, linked elements, and sub-items. | +| `IfcCostValue` | Applied monetary value breakdown conforming to `IFCAPPLIEDVALUE`. | + +## High-Level IfcOpenShell-Compatible API Facade + +| Type | Description | +|---|---| +| `IfcApi` | Unified top-level facade conforming to IfcOpenShell high-level API conventions (`IfcApi.Sequence`, `IfcApi.Cost`, `IfcApi.Spatial`, `IfcApi.Root`). | +| `IfcZone` | Functional spatial zone grouping spaces into thermal, fire safety, acoustic, or ventilation zones (`IFCZONE`). | diff --git a/src/IfcIO.Cli/Program.cs b/src/IfcIO.Cli/Program.cs index fa59e12..d98616e 100644 --- a/src/IfcIO.Cli/Program.cs +++ b/src/IfcIO.Cli/Program.cs @@ -43,6 +43,9 @@ public static int Main(string[] args) "import-csv" => ExecuteImportCsv(cmdArgs), "clash" => ExecuteClash(cmdArgs), "diff" => ExecuteDiff(cmdArgs), + "schedule" or "4d" => ExecuteSchedule(cmdArgs), + "cost" or "5d" => ExecuteCost(cmdArgs), + "bcf" => ExecuteBcf(cmdArgs), "view" or "html" => ExecuteView(cmdArgs), _ when File.Exists(args[0]) => ExecuteDefault(args), _ => PrintUnknownCommand(command) @@ -76,6 +79,9 @@ private static void PrintUsage() import-csv -o Update property sets from a CSV spreadsheet clash [fileB] Run 3D geometric clash detection (cross-model or self-clash) diff Compare two IFC model revisions (added, deleted, modified) + schedule Inspect 4D construction schedule, simulation progress, and export CSV + cost Inspect 5D cost schedule, budgets, takeoffs, and export CSV + bcf Inspect buildingSMART BCF 2.1 zip archive topics, viewpoints, comments view [-o out.html] Export standalone 3D WebGL HTML viewer (.html) Clash & Diff Options: @@ -1104,4 +1110,208 @@ private static int ExecuteView(string[] args) Console.ResetColor(); return 0; } + + private static int ExecuteSchedule(string[] args) + { + if (args.Length == 0) + { + Console.Error.WriteLine("Error: Missing IFC file path. Usage: ifcio schedule [--sim-date ] [--export-csv ]"); + return 1; + } + + string inputPath = args[0]; + if (!File.Exists(inputPath)) + { + Console.Error.WriteLine($"Error: File '{inputPath}' not found."); + return 1; + } + + var model = IfcModel.Open(inputPath); + if (model.WorkSchedules.Count == 0) + { + Console.WriteLine($"Model '{Path.GetFileName(inputPath)}' contains no 4D construction work schedules."); + return 0; + } + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"4D Construction Work Schedules: {model.WorkSchedules.Count} schedule(s)"); + Console.ResetColor(); + + string? simDateStr = GetOptionValue(args, "--sim-date"); + DateTime? simDate = null; + if (!string.IsNullOrEmpty(simDateStr) && DateTime.TryParse(simDateStr, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsedDate)) + { + simDate = parsedDate; + } + + foreach (var ws in model.WorkSchedules) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" Schedule: {ws.Name} (GlobalId: {ws.GlobalId})"); + Console.ResetColor(); + if (ws.StartTime.HasValue || ws.FinishTime.HasValue) + { + Console.WriteLine($" Planned: {ws.StartTime:yyyy-MM-dd} -> {ws.FinishTime:yyyy-MM-dd}"); + } + Console.WriteLine($" Total tasks: {ws.AllTasks.Count()}"); + + if (simDate.HasValue) + { + double prog = ws.GetProgressAt(simDate.Value); + var active = ws.GetActiveTasksAt(simDate.Value); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($" Simulation Progress at {simDate.Value:yyyy-MM-dd}: {prog:F1}% (Active tasks: {active.Count})"); + Console.ResetColor(); + foreach (var at in active) + { + Console.WriteLine($" * [{at.Identification ?? "WBS"}] {at.Name} ({at.TaskType}) Elements: {at.AssignedElements.Count}"); + } + } + else + { + foreach (var task in ws.AllTasks) + { + var start = task.Time?.ActualStartDate ?? task.Time?.PlannedStartDate; + var finish = task.Time?.ActualFinishDate ?? task.Time?.PlannedFinishDate; + string dates = (start.HasValue && finish.HasValue) ? $"[{start:yyyy-MM-dd} -> {finish:yyyy-MM-dd}]" : ""; + Console.WriteLine($" - [{task.Identification ?? "Task"}] {task.Name,-30} {dates,-26} Elements: {task.AssignedElements.Count}"); + } + } + } + + string? exportCsvPath = GetOptionValue(args, "--export-csv"); + if (!string.IsNullOrEmpty(exportCsvPath)) + { + using var writer = new StreamWriter(exportCsvPath, false, System.Text.Encoding.UTF8); + writer.WriteLine("Schedule;TaskName;WBS;TaskType;StartDate;FinishDate;AssignedElements"); + foreach (var ws in model.WorkSchedules) + { + foreach (var t in ws.AllTasks) + { + var start = t.Time?.ActualStartDate ?? t.Time?.PlannedStartDate; + var finish = t.Time?.ActualFinishDate ?? t.Time?.PlannedFinishDate; + writer.WriteLine($"{ws.Name};{t.Name};{t.Identification};{t.TaskType};{start:yyyy-MM-dd};{finish:yyyy-MM-dd};{t.AssignedElements.Count}"); + } + } + Console.WriteLine($"Schedule exported to CSV: '{exportCsvPath}'"); + } + + return 0; + } + + private static int ExecuteCost(string[] args) + { + if (args.Length == 0) + { + Console.Error.WriteLine("Error: Missing IFC file path. Usage: ifcio cost [--export-csv ]"); + return 1; + } + + string inputPath = args[0]; + if (!File.Exists(inputPath)) + { + Console.Error.WriteLine($"Error: File '{inputPath}' not found."); + return 1; + } + + var model = IfcModel.Open(inputPath); + if (model.CostSchedules.Count == 0) + { + Console.WriteLine($"Model '{Path.GetFileName(inputPath)}' contains no 5D cost schedules."); + return 0; + } + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"5D Cost Schedules & Budgets: {model.CostSchedules.Count} schedule(s)"); + Console.WriteLine($"Grand Total Cost: {model.CalculateTotalCost():N2}"); + Console.ResetColor(); + + foreach (var cs in model.CostSchedules) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" Schedule: {cs.Name} ({cs.ScheduleType}) - Currency: {cs.Currency}"); + Console.WriteLine($" Total Cost: {cs.GetTotalCost():N2} {cs.Currency}"); + Console.ResetColor(); + + foreach (var item in cs.AllCostItems) + { + string rateStr = item.UnitRate.HasValue ? $"Rate: {item.UnitRate.Value:N2}/{item.UnitBasis ?? "unit"}" : ""; + string qtyStr = item.Quantity.HasValue ? $"Qty: {item.Quantity.Value:N2}" : ""; + Console.WriteLine($" - [{item.CostCode ?? "CBS"}] {item.Name,-28} Cost: {item.TotalCost,12:N2} {cs.Currency} | {rateStr} {qtyStr} | Elements: {item.AssignedElements.Count}"); + } + } + + string? exportCsvPath = GetOptionValue(args, "--export-csv"); + if (!string.IsNullOrEmpty(exportCsvPath)) + { + using var writer = new StreamWriter(exportCsvPath, false, System.Text.Encoding.UTF8); + writer.WriteLine("Schedule;CostCode;Name;UnitRate;Quantity;UnitBasis;TotalCost;Currency;AssignedElements"); + foreach (var cs in model.CostSchedules) + { + foreach (var item in cs.AllCostItems) + { + writer.WriteLine($"{cs.Name};{item.CostCode};{item.Name};{item.UnitRate?.ToString(CultureInfo.InvariantCulture)};{item.Quantity?.ToString(CultureInfo.InvariantCulture)};{item.UnitBasis};{item.TotalCost.ToString(CultureInfo.InvariantCulture)};{cs.Currency};{item.AssignedElements.Count}"); + } + } + Console.WriteLine($"Cost schedule exported to CSV: '{exportCsvPath}'"); + } + + return 0; + } + + private static int ExecuteBcf(string[] args) + { + if (args.Length == 0) + { + Console.Error.WriteLine("Error: Missing BCF command. Usage: ifcio bcf "); + return 1; + } + + string subCmd = args[0].ToLowerInvariant(); + string bcfPath = (subCmd is "read" or "info" && args.Length > 1) ? args[1] : args[0]; + + if (!File.Exists(bcfPath)) + { + Console.Error.WriteLine($"Error: BCF archive '{bcfPath}' not found."); + return 1; + } + + var archive = Bcf.BcfArchive.Open(bcfPath); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("================================================================================"); + Console.WriteLine($" buildingSMART BCF Archive: {Path.GetFileName(bcfPath)}"); + Console.WriteLine("================================================================================"); + Console.ResetColor(); + + Console.WriteLine($" Total Topics: {archive.Topics.Count}"); + foreach (var topic in archive.Topics) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" Topic: [{topic.TopicStatus}] {topic.Title} ({topic.TopicType})"); + Console.ResetColor(); + Console.WriteLine($" Guid: {topic.TopicGuid}"); + Console.WriteLine($" Priority: {topic.Priority}"); + Console.WriteLine($" Author: {topic.CreationAuthor} on {topic.CreationDate:yyyy-MM-dd HH:mm}"); + if (!string.IsNullOrEmpty(topic.AssignedTo)) + { + Console.WriteLine($" Assigned To: {topic.AssignedTo}"); + } + if (!string.IsNullOrEmpty(topic.Description)) + { + Console.WriteLine($" Description: {topic.Description}"); + } + Console.WriteLine($" Viewpoints: {topic.Viewpoints.Count}"); + Console.WriteLine($" Comments: {topic.Comments.Count}"); + + foreach (var comment in topic.Comments) + { + Console.WriteLine($" * [{comment.Date:yyyy-MM-dd HH:mm}] {comment.Author}: {comment.Comment}"); + } + } + + return 0; + } } diff --git a/src/IfcIO/Api/IfcApi.cs b/src/IfcIO/Api/IfcApi.cs new file mode 100644 index 0000000..997229b --- /dev/null +++ b/src/IfcIO/Api/IfcApi.cs @@ -0,0 +1,206 @@ +using IfcIO.Geometry; +using IfcIO.Model; +using IfcIO.Model.Cost; +using IfcIO.Model.Sequence; +using IfcIO.Schema; + +namespace IfcIO.Api; + +/// +/// High-level API facade inspired by IfcOpenShell API (ifcopenshell.api), providing granular, +/// domain-driven operations for construction scheduling (4D), cost planning (5D), spatial hierarchy, and BIM modeling. +/// +public static class IfcApi +{ + /// 4D Construction sequencing and work schedule operations. + public static class Sequence + { + /// Creates and adds a new work schedule to the model. + public static IfcWorkSchedule AddWorkSchedule(IfcModel model, string name, string? description = null) + { + ArgumentNullException.ThrowIfNull(model); + return model.AddWorkSchedule(name, description); + } + + /// Creates and adds a task to a work schedule. + public static IfcTask AddTask( + IfcWorkSchedule schedule, + string name, + string? identification = null, + DateTime? start = null, + DateTime? finish = null, + IfcTaskType taskType = IfcTaskType.Construction) + { + ArgumentNullException.ThrowIfNull(schedule); + return schedule.AddTask(name, identification, start, finish, taskType); + } + + /// Assigns one or more building elements to a construction or demolition task. + public static void AssignProcess(IfcTask task, params IfcElement[] elements) + { + ArgumentNullException.ThrowIfNull(task); + ArgumentNullException.ThrowIfNull(elements); + task.AssignElements(elements); + } + + /// Creates a sequence dependency link between a predecessor and successor task. + public static IfcTaskRelationship AddSequence( + IfcTask predecessor, + IfcTask successor, + IfcSequenceType sequenceType = IfcSequenceType.FinishToStart, + TimeSpan? lag = null) + { + ArgumentNullException.ThrowIfNull(predecessor); + ArgumentNullException.ThrowIfNull(successor); + return predecessor.AddSuccessor(successor, sequenceType, lag); + } + } + + /// 5D Cost planning and budget estimation operations. + public static class Cost + { + /// Creates and adds a cost schedule to the model. + public static IfcCostSchedule AddCostSchedule( + IfcModel model, + string name, + string currency = "USD", + IfcCostScheduleType type = IfcCostScheduleType.CostPlan) + { + ArgumentNullException.ThrowIfNull(model); + return model.AddCostSchedule(name, currency, type); + } + + /// Creates and adds a cost item to a cost schedule. + public static IfcCostItem AddCostItem( + IfcCostSchedule schedule, + string name, + string? costCode = null, + double? unitRate = null, + double? quantity = null, + string? unitBasis = null) + { + ArgumentNullException.ThrowIfNull(schedule); + return schedule.AddCostItem(name, costCode, unitRate, quantity, unitBasis); + } + + /// Assigns one or more building elements to a cost item. + public static void AssignCostControl(IfcCostItem costItem, params IfcElement[] elements) + { + ArgumentNullException.ThrowIfNull(costItem); + ArgumentNullException.ThrowIfNull(elements); + foreach (var el in elements) costItem.AssignElement(el); + } + + /// Assigns a physical quantity to a cost item for automatic rate calculations. + public static void AssignQuantity(IfcCostItem costItem, IfcPhysicalQuantity quantity) + { + ArgumentNullException.ThrowIfNull(costItem); + ArgumentNullException.ThrowIfNull(quantity); + costItem.AssignQuantity(quantity); + } + } + + /// Spatial organization, storeys, and zone operations. + public static class Spatial + { + /// Creates and adds a spatial zone grouping one or more spaces. + public static IfcZone AddZone(IfcModel model, string name, string? zoneType = null, params IfcSpace[] spaces) + { + ArgumentNullException.ThrowIfNull(model); + return model.AddZone(name, zoneType, spaces); + } + + /// Assigns building elements to a building storey. + public static void AssignContainer(IfcBuildingStorey storey, params IfcElement[] elements) + { + ArgumentNullException.ThrowIfNull(storey); + ArgumentNullException.ThrowIfNull(elements); + foreach (var el in elements) + { + if (!storey.Elements.Contains(el)) + { + storey.Elements.Add(el); + } + } + } + } + + /// Root model and building element factory operations. + public static class Root + { + /// Initializes a new IFC project model. + public static IfcModel CreateProject(string projectName, IfcSchema schema = IfcSchema.Ifc4) => + IfcModel.Create(projectName, schema); + + /// Creates and registers a building storey. + public static IfcBuildingStorey CreateStorey(IfcModel model, string name, double elevation = 0.0) + { + ArgumentNullException.ThrowIfNull(model); + return model.AddStorey(name, elevation); + } + + /// Creates and adds an IFCWALL element to a storey. + public static IfcElement CreateWall(IfcBuildingStorey storey, string name, IfcMesh? mesh = null, IfcMaterial? material = null) + { + ArgumentNullException.ThrowIfNull(storey); + var wall = new IfcElement + { + Id = storey.Elements.Count + 1, + Name = name, + IfcType = "IFCWALL", + Mesh = mesh, + Material = material + }; + storey.Elements.Add(wall); + return wall; + } + + /// Creates and adds an IFCSLAB element to a storey. + public static IfcElement CreateSlab(IfcBuildingStorey storey, string name, IfcMesh? mesh = null, IfcMaterial? material = null) + { + ArgumentNullException.ThrowIfNull(storey); + var slab = new IfcElement + { + Id = storey.Elements.Count + 1, + Name = name, + IfcType = "IFCSLAB", + Mesh = mesh, + Material = material + }; + storey.Elements.Add(slab); + return slab; + } + + /// Creates and adds an IFCCOLUMN element to a storey. + public static IfcElement CreateColumn(IfcBuildingStorey storey, string name, IfcMesh? mesh = null, IfcMaterial? material = null) + { + ArgumentNullException.ThrowIfNull(storey); + var column = new IfcElement + { + Id = storey.Elements.Count + 1, + Name = name, + IfcType = "IFCCOLUMN", + Mesh = mesh, + Material = material + }; + storey.Elements.Add(column); + return column; + } + + /// Creates and adds an IFCBEAM element to a storey. + public static IfcElement CreateBeam(IfcBuildingStorey storey, string name, IfcMesh? mesh = null, IfcMaterial? material = null) + { + ArgumentNullException.ThrowIfNull(storey); + var beam = new IfcElement + { + Id = storey.Elements.Count + 1, + Name = name, + IfcType = "IFCBEAM", + Mesh = mesh, + Material = material + }; + storey.Elements.Add(beam); + return beam; + } + } +} diff --git a/src/IfcIO/Bcf/BcfArchive.cs b/src/IfcIO/Bcf/BcfArchive.cs new file mode 100644 index 0000000..53b83fa --- /dev/null +++ b/src/IfcIO/Bcf/BcfArchive.cs @@ -0,0 +1,74 @@ +namespace IfcIO.Bcf; + +/// +/// A managed buildingSMART BIM Collaboration Format (BCF 2.1) archive container holding issues, viewpoints, and comments. +/// +public sealed class BcfArchive +{ + /// The collection of coordination topics contained in this archive. + public List Topics { get; } = []; + + /// Optional project name or document title associated with this issue archive. + public string? ProjectName { get; set; } + + public BcfArchive() { } + + public BcfArchive(string? projectName = null) + { + ProjectName = projectName; + } + + /// Opens and parses a BCF 2.1 (.bcfzip) file from disk. + public static BcfArchive Open(string path) + { + ArgumentNullException.ThrowIfNull(path); + using var fs = File.OpenRead(path); + return Open(fs); + } + + /// Opens and parses a BCF 2.1 (.bcfzip) zip archive from a stream. + public static BcfArchive Open(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + var topics = BcfZipExporter.Read(stream); + var archive = new BcfArchive(); + archive.Topics.AddRange(topics); + return archive; + } + + /// Saves all topics into a BCF 2.1 (.bcfzip) file on disk. + public void Save(string path) + { + ArgumentNullException.ThrowIfNull(path); + BcfZipExporter.Save(path, Topics, ProjectName); + } + + /// Saves all topics into a stream as a BCF 2.1 (.bcfzip) zip archive. + public void Save(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + BcfZipExporter.Write(stream, Topics, ProjectName); + } + + /// Creates and adds a new topic to the archive. + public BcfTopic AddTopic(string title, string? description = null, string topicType = "Issue", string priority = "Normal") + { + var topic = new BcfTopic(title, description, topicType, priority); + Topics.Add(topic); + return topic; + } + + /// Finds all topics that reference or select a given building element by its IFC GlobalId. + public IReadOnlyList FindTopicsByElement(string ifcGuid) + { + ArgumentNullException.ThrowIfNull(ifcGuid); + return Topics.Where(t => t.Viewpoints.Any(v => v.SelectedElementGuids.Contains(ifcGuid, StringComparer.OrdinalIgnoreCase))).ToList(); + } + + /// Finds all topics matching a given status (e.g. "Open", "Resolved", "Closed"). + public IReadOnlyList FindTopicsByStatus(string status) + { + ArgumentNullException.ThrowIfNull(status); + return Topics.Where(t => string.Equals(t.TopicStatus, status, StringComparison.OrdinalIgnoreCase)).ToList(); + } +} diff --git a/src/IfcIO/Bcf/BcfTypes.cs b/src/IfcIO/Bcf/BcfTypes.cs index 7d85237..b3da873 100644 --- a/src/IfcIO/Bcf/BcfTypes.cs +++ b/src/IfcIO/Bcf/BcfTypes.cs @@ -23,8 +23,12 @@ public sealed class BcfViewpoint public Geometry.IfcVector3? CameraDirection { get; set; } public Geometry.IfcVector3? CameraUpVector { get; set; } public double? FieldOfView { get; set; } + public string? SnapshotFileName { get; set; } + public byte[]? SnapshotData { get; set; } + public bool DefaultVisibility { get; set; } = true; public List SelectedElementGuids { get; } = []; public List VisibleElementGuids { get; } = []; + public List HiddenElementGuids { get; } = []; } /// diff --git a/src/IfcIO/Bcf/BcfZipExporter.cs b/src/IfcIO/Bcf/BcfZipExporter.cs index 466aed8..432254f 100644 --- a/src/IfcIO/Bcf/BcfZipExporter.cs +++ b/src/IfcIO/Bcf/BcfZipExporter.cs @@ -82,6 +82,16 @@ public static void Write(Stream stream, IEnumerable topics, string? pr { WriteViewpointXml(xml, vp); } + + // Write snapshot if provided + if (vp.SnapshotData is not null && vp.SnapshotData.Length > 0) + { + string snapFileName = vIdx == 0 ? "snapshot.png" : $"snapshot_{vp.ViewpointGuid:D}.png"; + vp.SnapshotFileName = snapFileName; + var snapEntry = archive.CreateEntry($"{topicFolder}/{snapFileName}", CompressionLevel.Optimal); + using var snapStream = snapEntry.Open(); + snapStream.Write(vp.SnapshotData); + } } } } @@ -183,6 +193,18 @@ public static List Read(Stream stream) } } + // Read snapshots + var snapEntry = archive.GetEntry($"{folder}/snapshot.png") ?? + archive.Entries.FirstOrDefault(e => e.FullName.StartsWith($"{folder}/snapshot", StringComparison.OrdinalIgnoreCase) && e.FullName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)); + if (snapEntry is not null && topic.Viewpoints.Count > 0) + { + using var snapStream = snapEntry.Open(); + using var ms = new MemoryStream(); + snapStream.CopyTo(ms); + topic.Viewpoints[0].SnapshotData = ms.ToArray(); + topic.Viewpoints[0].SnapshotFileName = Path.GetFileName(snapEntry.FullName); + } + result.Add(topic); } @@ -222,6 +244,10 @@ private static void WriteMarkupXml(XmlWriter xml, BcfTopic topic, string project xml.WriteStartElement("Viewpoints"); xml.WriteAttributeString("Guid", vp.ViewpointGuid.ToString("D")); xml.WriteElementString("Viewpoint", vpFileName); + if (!string.IsNullOrEmpty(vp.SnapshotFileName)) + { + xml.WriteElementString("Snapshot", vp.SnapshotFileName); + } xml.WriteEndElement(); } @@ -285,17 +311,39 @@ private static void WriteViewpointXml(XmlWriter xml, BcfViewpoint vp) xml.WriteEndElement(); // PerspectiveCamera } - if (vp.SelectedElementGuids.Count > 0) + if (vp.SelectedElementGuids.Count > 0 || vp.HiddenElementGuids.Count > 0 || vp.VisibleElementGuids.Count > 0 || !vp.DefaultVisibility) { xml.WriteStartElement("Components"); - xml.WriteStartElement("Selection"); - foreach (var guid in vp.SelectedElementGuids) + if (!vp.DefaultVisibility || vp.HiddenElementGuids.Count > 0 || vp.VisibleElementGuids.Count > 0) { - xml.WriteStartElement("Component"); - xml.WriteAttributeString("IfcGuid", guid); - xml.WriteEndElement(); + xml.WriteStartElement("Visibility"); + xml.WriteAttributeString("DefaultVisibility", vp.DefaultVisibility ? "true" : "false"); + var exceptions = vp.DefaultVisibility ? vp.HiddenElementGuids : vp.VisibleElementGuids; + if (exceptions.Count > 0) + { + xml.WriteStartElement("Exceptions"); + foreach (var guid in exceptions) + { + xml.WriteStartElement("Component"); + xml.WriteAttributeString("IfcGuid", guid); + xml.WriteEndElement(); + } + xml.WriteEndElement(); // Exceptions + } + xml.WriteEndElement(); // Visibility + } + + if (vp.SelectedElementGuids.Count > 0) + { + xml.WriteStartElement("Selection"); + foreach (var guid in vp.SelectedElementGuids) + { + xml.WriteStartElement("Component"); + xml.WriteAttributeString("IfcGuid", guid); + xml.WriteEndElement(); + } + xml.WriteEndElement(); // Selection } - xml.WriteEndElement(); // Selection xml.WriteEndElement(); // Components } @@ -351,7 +399,30 @@ private static void WriteViewpointXml(XmlWriter xml, BcfViewpoint vp) } } - foreach (var c in root.FindDescendants("Component")) + var vis = root.FindDescendants("Visibility").FirstOrDefault(); + if (vis is not null) + { + string? defVis = (string?)vis.Attribute("DefaultVisibility"); + if (bool.TryParse(defVis, out bool dv)) + { + vp.DefaultVisibility = dv; + } + var ex = vis.FindChild("Exceptions"); + if (ex is not null) + { + foreach (var c in ex.FindDescendants("Component")) + { + string? g = (string?)c.Attribute("IfcGuid"); + if (!string.IsNullOrEmpty(g)) + { + if (vp.DefaultVisibility) vp.HiddenElementGuids.Add(g); + else vp.VisibleElementGuids.Add(g); + } + } + } + } + + foreach (var c in root.FindDescendants("Selection").SelectMany(s => s.FindDescendants("Component"))) { string? ifcGuid = (string?)c.Attribute("IfcGuid"); if (!string.IsNullOrEmpty(ifcGuid)) diff --git a/src/IfcIO/Core/StepWriter.cs b/src/IfcIO/Core/StepWriter.cs index 65934af..767ebe7 100644 --- a/src/IfcIO/Core/StepWriter.cs +++ b/src/IfcIO/Core/StepWriter.cs @@ -81,6 +81,10 @@ public void WriteTo( public const string Null = "$"; + public static string Number(int v) => v.ToString(CultureInfo.InvariantCulture); + + public static string Number(int? v) => v.HasValue ? v.Value.ToString(CultureInfo.InvariantCulture) : "$"; + public static string Str(string? s) => s is null ? "$" : $"'{Escape(s)}'"; public static string Enum(string v) => $".{v}."; diff --git a/src/IfcIO/Exporters/IfcHtmlViewerExporter.cs b/src/IfcIO/Exporters/IfcHtmlViewerExporter.cs index 28b00c8..f51dcba 100644 --- a/src/IfcIO/Exporters/IfcHtmlViewerExporter.cs +++ b/src/IfcIO/Exporters/IfcHtmlViewerExporter.cs @@ -18,6 +18,7 @@ public sealed class IfcHtmlViewerOptions public bool IncludeSectioning { get; set; } = true; public bool IncludeMeasure { get; set; } = true; public bool IncludeGrids { get; set; } = true; + public bool IncludeTimeLiner { get; set; } = true; public Grids.IfcGridSystem? GridSystem { get; set; } public int MaxMeshVertices { get; set; } = 500000; } @@ -1036,6 +1037,39 @@ private static string BuildJsonPayload(IfcModel model, IfcHtmlViewerOptions opti writer.WriteEndArray(); } + // 4D Work Schedules + if (options.IncludeTimeLiner && model.WorkSchedules.Count > 0) + { + writer.WriteStartArray("workSchedules"); + foreach (var ws in model.WorkSchedules) + { + writer.WriteStartObject(); + writer.WriteString("name", ws.Name); + writer.WriteStartArray("tasks"); + foreach (var task in ws.AllTasks) + { + writer.WriteStartObject(); + writer.WriteString("name", task.Name); + if (!string.IsNullOrEmpty(task.Identification)) writer.WriteString("wbs", task.Identification); + writer.WriteString("type", task.TaskType.ToString()); + var start = task.Time?.ActualStartDate ?? task.Time?.PlannedStartDate; + var finish = task.Time?.ActualFinishDate ?? task.Time?.PlannedFinishDate; + if (start.HasValue) writer.WriteString("start", start.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + if (finish.HasValue) writer.WriteString("finish", finish.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + writer.WriteStartArray("elementIds"); + foreach (var el in task.AssignedElements) + { + writer.WriteNumberValue(el.Id); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); writer.Flush(); diff --git a/src/IfcIO/IfcModel.cs b/src/IfcIO/IfcModel.cs index b73d93e..d274d8b 100644 --- a/src/IfcIO/IfcModel.cs +++ b/src/IfcIO/IfcModel.cs @@ -39,6 +39,9 @@ public sealed class IfcModel public List TypeProducts { get; } = []; public List Groups { get; } = []; public List Systems { get; } = []; + public List Zones { get; } = []; + public List WorkSchedules { get; } = []; + public List CostSchedules { get; } = []; /// Spatial grid system (axes and levels) associated with the IFC model. public Grids.IfcGridSystem? GridSystem { get; set; } @@ -357,6 +360,74 @@ public IfcGroup AddGroup(string name, string? description = null, IEnumerable + /// Creates and registers a spatial zone grouping spaces sharing thermal, fire, or acoustic classifications. + /// Conforms to buildingSMART standard IFCZONE. + /// + public IfcZone AddZone(string name, string? zoneType = null, IEnumerable? spaces = null) + { + ArgumentNullException.ThrowIfNull(name); + var zone = new IfcZone(name, zoneType); + if (spaces is not null) + { + foreach (var s in spaces) zone.AddSpace(s); + } + Zones.Add(zone); + return zone; + } + + /// + /// Creates and registers a 4D construction work schedule. + /// Conforms to buildingSMART standard IFCWORKSCHEDULE. + /// + public Model.Sequence.IfcWorkSchedule AddWorkSchedule(string name, string? description = null) + { + ArgumentNullException.ThrowIfNull(name); + var schedule = new Model.Sequence.IfcWorkSchedule(name, description); + WorkSchedules.Add(schedule); + return schedule; + } + + /// + /// Creates and registers a 5D cost schedule / budget plan. + /// Conforms to buildingSMART standard IFCCOSTSCHEDULE. + /// + public Model.Cost.IfcCostSchedule AddCostSchedule( + string name, + string currency = "USD", + Model.Cost.IfcCostScheduleType type = Model.Cost.IfcCostScheduleType.CostPlan) + { + ArgumentNullException.ThrowIfNull(name); + var schedule = new Model.Cost.IfcCostSchedule(name, currency, type); + CostSchedules.Add(schedule); + return schedule; + } + + /// Calculates the grand total cost across all cost schedules in the model. + public double CalculateTotalCost() => CostSchedules.Sum(s => s.GetTotalCost()); + + /// Gets the 4D construction simulation state of an element at the specified date using the active work schedule. + public Model.Sequence.IfcElementSimulationState GetElementSimulationStateAt(IfcElement element, DateTime date) + { + ArgumentNullException.ThrowIfNull(element); + if (WorkSchedules.Count == 0) return Model.Sequence.IfcElementSimulationState.Constructed; + return WorkSchedules[0].GetElementStateAt(element, date); + } + + /// Gets the overall 4D construction schedule progress (0.0 to 100.0) at the specified date. + public double GetSimulationProgressAt(DateTime date) + { + if (WorkSchedules.Count == 0) return 100.0; + return WorkSchedules[0].GetProgressAt(date); + } + + /// Imports a BCF 2.1 archive (.bcfzip) file from disk. + public static List ImportBcf(string path) + { + ArgumentNullException.ThrowIfNull(path); + return Bcf.BcfArchive.Open(path).Topics; + } + /// /// Adds an element defined by a 3D solid clipped by an infinite plane (Constructive Solid Geometry / Boolean clipping). /// Conforms to buildingSMART IFCBOOLEANCLIPPINGRESULT. @@ -1025,6 +1096,233 @@ public void Save(TextWriter writer, string fileName = "model.ifc") } } + // Zones (IFCZONE) + foreach (var zone in Zones) + { + string zoneGuid = zone.GlobalId ?? IfcGuid.NewGuid(); + int zoneId = w.Add("IFCZONE", + $"{StepWriter.Str(zoneGuid)},{StepWriter.Ref(ownerHistoryId)},{StepWriter.Str(zone.Name)},{StepWriter.Str(zone.Description)},{StepWriter.Str(zone.ZoneType)}"); + + var spaceMemberIds = new List(); + foreach (var space in zone.Spaces) + { + if (elementEntityMap.TryGetValue(space, out int sId)) + { + spaceMemberIds.Add(sId); + } + } + + if (spaceMemberIds.Count > 0) + { + w.Add("IFCRELASSIGNSTOGROUP", + $"{StepWriter.Str(IfcGuid.NewGuid())},{StepWriter.Ref(ownerHistoryId)},$,$,{StepWriter.List(spaceMemberIds.Select(StepWriter.Ref))},$,{StepWriter.Ref(zoneId)}"); + } + } + + // 4D Construction Work Schedules (IFCWORKSCHEDULE, IFCTASK, IFCTASKTIME, IFCRELASSIGNSTOPROCESS, IFCRELSEQUENCE) + var taskEntityMap = new Dictionary(); + foreach (var ws in WorkSchedules) + { + string wsGuid = ws.GlobalId ?? IfcGuid.NewGuid(); + string wsStartStr = ws.StartTime.HasValue ? StepWriter.Str(ws.StartTime.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)) : "$"; + string wsFinStr = ws.FinishTime.HasValue ? StepWriter.Str(ws.FinishTime.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)) : "$"; + int wsId = (Schema == IfcSchema.Ifc4) + ? w.Add("IFCWORKSCHEDULE", $"{StepWriter.Str(wsGuid)},{StepWriter.Ref(ownerHistoryId)},{StepWriter.Str(ws.Name)},{StepWriter.Str(ws.Description)},$,{StepWriter.Str(ws.CreationDate.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture))},$,$,$,$,{wsStartStr},{wsFinStr}") + : w.Add("IFCWORKSCHEDULE", $"{StepWriter.Str(wsGuid)},{StepWriter.Ref(ownerHistoryId)},{StepWriter.Str(ws.Name)},{StepWriter.Str(ws.Description)},$,$,{StepWriter.Str(ws.CreationDate.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture))},$,$,$,{wsStartStr},{wsFinStr}"); + + var scheduleTaskIds = new List(); + foreach (var task in ws.AllTasks) + { + string tGuid = task.GlobalId ?? IfcGuid.NewGuid(); + string tTypeStr = $".{task.TaskType.ToString().ToUpperInvariant()}."; + bool isMilestone = task.Time?.IsMilestone == true; + string isMileStr = isMilestone ? ".T." : ".F."; + + int? timeId = null; + if (task.Time is not null) + { + string pStart = task.Time.PlannedStartDate.HasValue ? StepWriter.Str(task.Time.PlannedStartDate.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)) : "$"; + string pFin = task.Time.PlannedFinishDate.HasValue ? StepWriter.Str(task.Time.PlannedFinishDate.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)) : "$"; + string aStart = task.Time.ActualStartDate.HasValue ? StepWriter.Str(task.Time.ActualStartDate.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)) : "$"; + string aFin = task.Time.ActualFinishDate.HasValue ? StepWriter.Str(task.Time.ActualFinishDate.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)) : "$"; + string compStr = task.Time.Completion.HasValue ? StepWriter.Real(task.Time.Completion.Value) : "$"; + + if (Schema == IfcSchema.Ifc4) + { + timeId = w.Add("IFCTASKTIME", + $"$,$,.NOTDEFINED.,$,$,$,$,$,$,$,{pStart},{pFin},{aStart},{aFin},{compStr},$"); + } + else + { + timeId = w.Add("IFCSCHEDULETIMECONTROL", + $"$,$,$,$,$,$,$,$,$,$,{aStart},{aFin},{pStart},{pFin},{compStr},$"); + } + } + + string timeRef = timeId.HasValue ? StepWriter.Ref(timeId.Value) : "$"; + string prioStr = task.Priority.HasValue ? StepWriter.Number(task.Priority.Value) : "$"; + int taskId = (Schema == IfcSchema.Ifc4) + ? w.Add("IFCTASK", $"{StepWriter.Str(tGuid)},{StepWriter.Ref(ownerHistoryId)},{StepWriter.Str(task.Name)},{StepWriter.Str(task.Description)},$,{StepWriter.Str(task.Identification)},{StepWriter.Str(task.Status)},$,{isMileStr},{prioStr},{timeRef},{tTypeStr}") + : w.Add("IFCTASK", $"{StepWriter.Str(tGuid)},{StepWriter.Ref(ownerHistoryId)},{StepWriter.Str(task.Name)},{StepWriter.Str(task.Description)},$,{StepWriter.Str(task.Identification)},{StepWriter.Str(task.Status)},$,{isMileStr},{prioStr}"); + + taskEntityMap[task] = taskId; + scheduleTaskIds.Add(taskId); + + // Assign elements via IFCRELASSIGNSTOPROCESS + var assignedElemIds = new List(); + foreach (var el in task.AssignedElements) + { + if (elementEntityMap.TryGetValue(el, out int elId)) + { + assignedElemIds.Add(elId); + } + } + if (assignedElemIds.Count > 0) + { + w.Add("IFCRELASSIGNSTOPROCESS", + $"{StepWriter.Str(IfcGuid.NewGuid())},{StepWriter.Ref(ownerHistoryId)},$,$,{StepWriter.List(assignedElemIds.Select(StepWriter.Ref))},$,{StepWriter.Ref(taskId)},$"); + } + } + + if (scheduleTaskIds.Count > 0) + { + w.Add("IFCRELASSIGNSTOCONTROL", + $"{StepWriter.Str(IfcGuid.NewGuid())},{StepWriter.Ref(ownerHistoryId)},$,$,{StepWriter.List(scheduleTaskIds.Select(StepWriter.Ref))},$,{StepWriter.Ref(wsId)}"); + } + } + + // Sequence relationships (IFCRELSEQUENCE) + foreach (var ws in WorkSchedules) + { + foreach (var task in ws.AllTasks) + { + if (!taskEntityMap.TryGetValue(task, out int relProcId)) continue; + + foreach (var succ in task.Successors) + { + if (taskEntityMap.TryGetValue(succ.RelatedProcess, out int succId)) + { + string seqEnum = succ.SequenceType switch + { + Model.Sequence.IfcSequenceType.StartToStart => ".START_START.", + Model.Sequence.IfcSequenceType.StartToFinish => ".START_FINISH.", + Model.Sequence.IfcSequenceType.FinishToFinish => ".FINISH_FINISH.", + _ => ".FINISH_START." + }; + w.Add("IFCRELSEQUENCE", + $"{StepWriter.Str(IfcGuid.NewGuid())},{StepWriter.Ref(ownerHistoryId)},{StepWriter.Str(succ.Description)},$,{StepWriter.Ref(relProcId)},{StepWriter.Ref(succId)},$,{seqEnum}"); + } + } + } + } + + // 5D Cost Schedules & Cost Items (IFCCOSTSCHEDULE, IFCCOSTITEM, IFCAPPLIEDVALUE, IFCRELASSIGNSTOCONTROL, IFCRELNESTING) + foreach (var cs in CostSchedules) + { + string csGuid = cs.GlobalId ?? IfcGuid.NewGuid(); + string csTypeEnum = $".{cs.ScheduleType.ToString().ToUpperInvariant()}."; + int csId = (Schema == IfcSchema.Ifc4) + ? w.Add("IFCCOSTSCHEDULE", $"{StepWriter.Str(csGuid)},{StepWriter.Ref(ownerHistoryId)},{StepWriter.Str(cs.Name)},{StepWriter.Str(cs.Description)},{StepWriter.Str(cs.Currency)},$,$,$,{StepWriter.Str(cs.Status)},$,{StepWriter.Str(cs.UpdateDate.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture))},$,{csTypeEnum}") + : w.Add("IFCCOSTSCHEDULE", $"{StepWriter.Str(csGuid)},{StepWriter.Ref(ownerHistoryId)},{StepWriter.Str(cs.Name)},{StepWriter.Str(cs.Description)},{StepWriter.Str(cs.Currency)},$,$,$,{StepWriter.Str(cs.Status)},$,{StepWriter.Str(cs.UpdateDate.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture))},$,{csTypeEnum}"); + + var csItemIds = new List(); + var itemEntityMap = new Dictionary(); + + foreach (var item in cs.AllCostItems) + { + string ciGuid = item.GlobalId ?? IfcGuid.NewGuid(); + + // Cost values (IFCAPPLIEDVALUE) + var appliedValIds = new List(); + if (item.CostValues.Count > 0) + { + foreach (var cv in item.CostValues) + { + int avId = w.Add("IFCAPPLIEDVALUE", + $"{StepWriter.Str(cv.Name)},$,IFCMONETARYMEASURE({StepWriter.Real(cv.Amount)}),$,$,$,{StepWriter.Str(cv.Category ?? cv.Currency)},$,$,$"); + appliedValIds.Add(avId); + } + } + if (item.UnitRate.HasValue) + { + int avId = w.Add("IFCAPPLIEDVALUE", + $"{StepWriter.Str("UnitRate")},{StepWriter.Str(item.UnitBasis)},IFCMONETARYMEASURE({StepWriter.Real(item.UnitRate.Value)}),$,$,$,'UnitRate',$,$,$"); + appliedValIds.Add(avId); + } + + // Quantities (IFCQUANTITY*) + var qtyValIds = new List(); + if (item.Quantity.HasValue) + { + string qtyType = item.UnitBasis?.ToLowerInvariant() switch + { + "m2" or "sqm" => "IFCQUANTITYAREA", + "m3" or "cum" => "IFCQUANTITYVOLUME", + "m" or "lm" => "IFCQUANTITYLENGTH", + _ => "IFCQUANTITYCOUNT" + }; + int qId = w.Add(qtyType, $"{StepWriter.Str("Quantity")},{StepWriter.Str(item.UnitBasis)},$,{StepWriter.Real(item.Quantity.Value)},$"); + qtyValIds.Add(qId); + } + + string costValuesArg = appliedValIds.Count > 0 ? StepWriter.List(appliedValIds.Select(StepWriter.Ref)) : "$"; + string quantitiesArg = qtyValIds.Count > 0 ? StepWriter.List(qtyValIds.Select(StepWriter.Ref)) : "$"; + + int ciId = (Schema == IfcSchema.Ifc4) + ? w.Add("IFCCOSTITEM", $"{StepWriter.Str(ciGuid)},{StepWriter.Ref(ownerHistoryId)},{StepWriter.Str(item.Name)},{StepWriter.Str(item.Description)},$,{StepWriter.Str(item.CostCode)},{costValuesArg},{quantitiesArg}") + : w.Add("IFCCOSTITEM", $"{StepWriter.Str(ciGuid)},{StepWriter.Ref(ownerHistoryId)},{StepWriter.Str(item.Name)},{StepWriter.Str(item.Description)},$,{StepWriter.Str(item.CostCode)}"); + + itemEntityMap[item] = ciId; + + if (cs.RootCostItems.Contains(item)) + { + csItemIds.Add(ciId); + } + + if (Schema == IfcSchema.Ifc2x3 && (appliedValIds.Count > 0 || qtyValIds.Count > 0)) + { + var allValRefs = appliedValIds.Concat(qtyValIds).ToList(); + w.Add("IFCRELASSIGNSTOCONTROL", + $"{StepWriter.Str(IfcGuid.NewGuid())},{StepWriter.Ref(ownerHistoryId)},$,$,{StepWriter.List(allValRefs.Select(StepWriter.Ref))},$,{StepWriter.Ref(ciId)}"); + } + + // Assigned elements + var assignedElemIds = new List(); + foreach (var el in item.AssignedElements) + { + if (elementEntityMap.TryGetValue(el, out int elId)) + { + assignedElemIds.Add(elId); + } + } + if (assignedElemIds.Count > 0) + { + w.Add("IFCRELASSIGNSTOCONTROL", + $"{StepWriter.Str(IfcGuid.NewGuid())},{StepWriter.Ref(ownerHistoryId)},$,$,{StepWriter.List(assignedElemIds.Select(StepWriter.Ref))},$,{StepWriter.Ref(ciId)}"); + } + } + + // Sub-cost items via IFCRELNESTING + foreach (var item in cs.AllCostItems) + { + if (item.SubCostItems.Count > 0 && itemEntityMap.TryGetValue(item, out int parentCiId)) + { + var subCiIds = item.SubCostItems.Where(s => itemEntityMap.ContainsKey(s)).Select(s => itemEntityMap[s]).ToList(); + if (subCiIds.Count > 0) + { + w.Add("IFCRELNESTING", + $"{StepWriter.Str(IfcGuid.NewGuid())},{StepWriter.Ref(ownerHistoryId)},$,$,{StepWriter.Ref(parentCiId)},{StepWriter.List(subCiIds.Select(StepWriter.Ref))}"); + } + } + } + + if (csItemIds.Count > 0) + { + w.Add("IFCRELASSIGNSTOCONTROL", + $"{StepWriter.Str(IfcGuid.NewGuid())},{StepWriter.Ref(ownerHistoryId)},$,$,{StepWriter.List(csItemIds.Select(StepWriter.Ref))},$,{StepWriter.Ref(csId)}"); + } + } + // 9. Material Layers & Profiles var materialEntityMap = new Dictionary(); foreach (var mat in Materials) @@ -1292,6 +1590,14 @@ public static IfcModel Open(string path) return Open(fileReader, Path.GetFileNameWithoutExtension(path)); } + /// Parses an existing IFC STEP physical file or stream into a structured model. + public static IfcModel Open(Stream stream, string? modelName = null) + { + ArgumentNullException.ThrowIfNull(stream); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 65536, leaveOpen: true); + return Open(reader, modelName); + } + /// Parses an existing IFC STEP stream into a structured model with storeys, elements, properties, and meshes. public static IfcModel Open(TextReader reader, string? modelName = null) { @@ -1724,6 +2030,22 @@ openElem is IfcOpeningElement opening && groupMap[grpEnt.Id] = grp; } + foreach (var zoneEnt in step.OfType("IFCZONE")) + { + string zoneGuid = zoneEnt.GetString(0) ?? IfcGuid.NewGuid(); + string zName = zoneEnt.GetString(2) ?? "Zone"; + string? zDesc = zoneEnt.GetString(3); + string? zType = zoneEnt.GetString(4); + + var zone = new IfcZone(zName, zType) + { + GlobalId = zoneGuid, + Description = zDesc, + }; + model.Zones.Add(zone); + groupMap[zoneEnt.Id] = zone; + } + foreach (var relGroup in step.OfType("IFCRELASSIGNSTOGROUP")) { int? groupId = relGroup.GetRef(6); @@ -1732,9 +2054,296 @@ openElem is IfcOpeningElement opening && var memberIds = relGroup.GetRefList(4); foreach (int mId in memberIds) { - if (allElementsMap.TryGetValue(mId, out var elem) && !targetGroup.Elements.Contains(elem)) + if (allElementsMap.TryGetValue(mId, out var elem)) + { + if (targetGroup is IfcZone z && elem is IfcSpace sp) + { + z.AddSpace(sp); + } + else if (!targetGroup.Elements.Contains(elem)) + { + targetGroup.Elements.Add(elem); + } + } + } + } + } + + // 9b. Parse 4D Construction Work Schedules (IFCWORKSCHEDULE, IFCTASK, IFCTASKTIME, IFCSCHEDULETIMECONTROL, IFCRELASSIGNSTOPROCESS, IFCRELSEQUENCE) + var taskMap = new Dictionary(); + foreach (var tEnt in step.OfType("IFCTASK")) + { + string tGuid = tEnt.GetString(0) ?? IfcGuid.NewGuid(); + string tName = tEnt.GetString(2) ?? "Task"; + string? tDesc = tEnt.GetString(3); + string? tIdent = tEnt.GetString(5); + string? tStatus = tEnt.GetString(6); + int? priority = tEnt.GetInt(9); + + var task = new Model.Sequence.IfcTask(tName, tIdent) + { + GlobalId = tGuid, + Description = tDesc, + Status = tStatus, + Priority = priority + }; + + var timeRef = tEnt.GetRef(10); + if (timeRef.HasValue) + { + var timeEnt = step.Find(timeRef.Value); + if (timeEnt is not null) + { + task.Time = new Model.Sequence.IfcTaskTime(); + if (DateTime.TryParse(timeEnt.GetString(10), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var pStart)) + task.Time.PlannedStartDate = pStart; + if (DateTime.TryParse(timeEnt.GetString(11), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var pFin)) + task.Time.PlannedFinishDate = pFin; + if (DateTime.TryParse(timeEnt.GetString(12), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var aStart)) + task.Time.ActualStartDate = aStart; + if (DateTime.TryParse(timeEnt.GetString(13), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var aFin)) + task.Time.ActualFinishDate = aFin; + task.Time.Completion = timeEnt.GetReal(14); + } + } + + taskMap[tEnt.Id] = task; + } + + foreach (var wsEnt in step.OfType("IFCWORKSCHEDULE")) + { + string wsGuid = wsEnt.GetString(0) ?? IfcGuid.NewGuid(); + string wsName = wsEnt.GetString(2) ?? "Schedule"; + string? wsDesc = wsEnt.GetString(3); + var ws = new Model.Sequence.IfcWorkSchedule(wsName, wsDesc) + { + GlobalId = wsGuid + }; + if (DateTime.TryParse(wsEnt.GetString(10), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var wsStart)) + ws.StartTime = wsStart; + if (DateTime.TryParse(wsEnt.GetString(11), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var wsFin)) + ws.FinishTime = wsFin; + + model.WorkSchedules.Add(ws); + } + + // Connect tasks to elements via IFCRELASSIGNSTOPROCESS + foreach (var relProc in step.OfType("IFCRELASSIGNSTOPROCESS")) + { + var procRef = relProc.GetRef(6); + if (procRef.HasValue && taskMap.TryGetValue(procRef.Value, out var task)) + { + foreach (var elId in relProc.GetRefList(4)) + { + if (allElementsMap.TryGetValue(elId, out var el)) + { + task.AssignElement(el); + } + } + } + } + + // Connect sequence dependencies via IFCRELSEQUENCE + foreach (var relSeq in step.OfType("IFCRELSEQUENCE")) + { + var relProcRef = relSeq.GetRef(4); + var relSuccRef = relSeq.GetRef(5); + if (relProcRef.HasValue && relSuccRef.HasValue && + taskMap.TryGetValue(relProcRef.Value, out var pred) && + taskMap.TryGetValue(relSuccRef.Value, out var succ)) + { + string? seqTypeStr = relSeq.GetString(7); + var seqType = seqTypeStr?.Contains("START_START", StringComparison.OrdinalIgnoreCase) == true ? Model.Sequence.IfcSequenceType.StartToStart : + seqTypeStr?.Contains("START_FINISH", StringComparison.OrdinalIgnoreCase) == true ? Model.Sequence.IfcSequenceType.StartToFinish : + seqTypeStr?.Contains("FINISH_FINISH", StringComparison.OrdinalIgnoreCase) == true ? Model.Sequence.IfcSequenceType.FinishToFinish : + Model.Sequence.IfcSequenceType.FinishToStart; + pred.AddSuccessor(succ, seqType); + } + } + + // 9c. Parse 5D Cost Schedules & Cost Items (IFCCOSTSCHEDULE, IFCCOSTITEM, IFCAPPLIEDVALUE, IFCRELASSIGNSTOCONTROL, IFCRELNESTING) + var costItemMap = new Dictionary(); + foreach (var ciEnt in step.OfType("IFCCOSTITEM")) + { + string ciGuid = ciEnt.GetString(0) ?? IfcGuid.NewGuid(); + string ciName = ciEnt.GetString(2) ?? "Cost Item"; + string? ciDesc = ciEnt.GetString(3); + string? ciCode = ciEnt.GetString(5); + + var ci = new Model.Cost.IfcCostItem(ciName, ciCode) + { + GlobalId = ciGuid, + Description = ciDesc + }; + + // Direct references in IFC4 (argument 6: CostValues, argument 7: CostQuantities) + foreach (int vRef in ciEnt.GetRefList(6)) + { + var vEnt = step.Find(vRef); + if (vEnt is not null && vEnt.Type.Equals("IFCAPPLIEDVALUE", StringComparison.OrdinalIgnoreCase)) + { + string? vName = vEnt.GetString(0); + string? vDesc = vEnt.GetString(1); + string? rawAmount = vEnt.GetRaw(2); + string cleanAmount = ExtractNominalValue(rawAmount); + string? vCat = vEnt.GetString(6); + + if (double.TryParse(cleanAmount, NumberStyles.Float, CultureInfo.InvariantCulture, out double amt)) { - targetGroup.Elements.Add(elem); + if (string.Equals(vName, "UnitRate", StringComparison.OrdinalIgnoreCase) || + string.Equals(vCat, "UnitRate", StringComparison.OrdinalIgnoreCase)) + { + ci.UnitRate = amt; + if (!string.IsNullOrEmpty(vDesc)) ci.UnitBasis = vDesc; + } + else + { + ci.AddCostValue(amt, "USD", vCat, vName); + } + } + } + } + + foreach (int qRef in ciEnt.GetRefList(7)) + { + var qEnt = step.Find(qRef); + if (qEnt is not null && qEnt.Type.StartsWith("IFCQUANTITY", StringComparison.OrdinalIgnoreCase)) + { + double? qVal = qEnt.GetReal(3); + if (qVal.HasValue) + { + ci.Quantity = qVal.Value; + string? qDesc = qEnt.GetString(1); + if (!string.IsNullOrEmpty(qDesc)) ci.UnitBasis = qDesc; + } + } + } + + costItemMap[ciEnt.Id] = ci; + } + + foreach (var csEnt in step.OfType("IFCCOSTSCHEDULE")) + { + string csGuid = csEnt.GetString(0) ?? IfcGuid.NewGuid(); + string csName = csEnt.GetString(2) ?? "Cost Schedule"; + string? csDesc = csEnt.GetString(3); + string? csCurrency = csEnt.GetString(4); + var cs = new Model.Cost.IfcCostSchedule(csName, string.IsNullOrWhiteSpace(csCurrency) ? "USD" : csCurrency) + { + GlobalId = csGuid, + Description = csDesc + }; + + string? typeRaw = csEnt.GetRaw(12) ?? csEnt.GetRaw(6); + if (!string.IsNullOrWhiteSpace(typeRaw)) + { + string cleanType = typeRaw.Trim('.').Replace("_", ""); + if (Enum.TryParse(cleanType, true, out var parsedType)) + { + cs.ScheduleType = parsedType; + } + } + + model.CostSchedules.Add(cs); + } + + // Connect sub-items via IFCRELNESTING + foreach (var relNest in step.OfType("IFCRELNESTING")) + { + int? parentRef = relNest.GetRef(4); + if (parentRef.HasValue && costItemMap.TryGetValue(parentRef.Value, out var parentItem)) + { + foreach (int childRef in relNest.GetRefList(5)) + { + if (costItemMap.TryGetValue(childRef, out var childItem) && !parentItem.SubCostItems.Contains(childItem)) + { + parentItem.SubCostItems.Add(childItem); + } + } + } + } + + // Connect control assignments via IFCRELASSIGNSTOCONTROL + foreach (var relCtrl in step.OfType("IFCRELASSIGNSTOCONTROL")) + { + var ctrlRef = relCtrl.GetRef(6); + if (!ctrlRef.HasValue) continue; + + string? ctrlGuid = step.Find(ctrlRef.Value)?.GetString(0); + + // Connect to work schedule + var ws = model.WorkSchedules.Find(s => s.GlobalId == ctrlGuid); + if (ws is not null) + { + foreach (var rId in relCtrl.GetRefList(4)) + { + if (taskMap.TryGetValue(rId, out var task) && !ws.RootTasks.Contains(task)) + { + ws.RootTasks.Add(task); + } + } + continue; + } + + // Connect to cost schedule + var cs = model.CostSchedules.Find(s => s.GlobalId == ctrlGuid); + if (cs is not null) + { + foreach (var rId in relCtrl.GetRefList(4)) + { + if (costItemMap.TryGetValue(rId, out var item) && !cs.RootCostItems.Contains(item)) + { + cs.RootCostItems.Add(item); + } + } + continue; + } + + // Connect elements or applied values/quantities to cost item + if (costItemMap.TryGetValue(ctrlRef.Value, out var costItem)) + { + foreach (var rId in relCtrl.GetRefList(4)) + { + if (allElementsMap.TryGetValue(rId, out var el)) + { + costItem.AssignElement(el); + } + else + { + var relatedEnt = step.Find(rId); + if (relatedEnt is not null) + { + if (relatedEnt.Type.Equals("IFCAPPLIEDVALUE", StringComparison.OrdinalIgnoreCase)) + { + string? vName = relatedEnt.GetString(0); + string? vDesc = relatedEnt.GetString(1); + string cleanAmount = ExtractNominalValue(relatedEnt.GetRaw(2)); + string? vCat = relatedEnt.GetString(6); + if (double.TryParse(cleanAmount, NumberStyles.Float, CultureInfo.InvariantCulture, out double amt)) + { + if (string.Equals(vName, "UnitRate", StringComparison.OrdinalIgnoreCase) || + string.Equals(vCat, "UnitRate", StringComparison.OrdinalIgnoreCase)) + { + costItem.UnitRate = amt; + if (!string.IsNullOrEmpty(vDesc)) costItem.UnitBasis = vDesc; + } + else + { + costItem.AddCostValue(amt, "USD", vCat, vName); + } + } + } + else if (relatedEnt.Type.StartsWith("IFCQUANTITY", StringComparison.OrdinalIgnoreCase)) + { + double? qVal = relatedEnt.GetReal(3); + if (qVal.HasValue) + { + costItem.Quantity = qVal.Value; + string? qDesc = relatedEnt.GetString(1); + if (!string.IsNullOrEmpty(qDesc)) costItem.UnitBasis = qDesc; + } + } + } } } } diff --git a/src/IfcIO/Model/Cost/IfcCostTypes.cs b/src/IfcIO/Model/Cost/IfcCostTypes.cs new file mode 100644 index 0000000..d51b687 --- /dev/null +++ b/src/IfcIO/Model/Cost/IfcCostTypes.cs @@ -0,0 +1,221 @@ +using System.Globalization; + +namespace IfcIO.Model.Cost; + +/// +/// Predefined classification for cost schedules conforming to buildingSMART standard IFCCOSTSCHEDULETYPEENUM. +/// +public enum IfcCostScheduleType +{ + Budget, + CostPlan, + Estimate, + Tender, + Actual, + Valuation, + Pricing, + UserDefined, + NotDefined +} + +/// +/// A monetary or rate value associated with a cost item. +/// Conforms to buildingSMART standard IFCAPPLIEDVALUE. +/// +public sealed class IfcCostValue +{ + public string? Name { get; set; } + + /// Cost category (e.g. Material, Labor, Equipment, Subcontractor, Overhead, Profit). + public string? Category { get; set; } + + /// The numeric cost amount. + public double Amount { get; set; } + + /// Currency code (e.g. USD, EUR, GBP, JPY). + public string Currency { get; set; } = "USD"; + + /// Measurement unit basis (e.g. "m", "m2", "m3", "kg", "hour", "unit"). + public string? UnitBasis { get; set; } + + public IfcCostValue() { } + + public IfcCostValue(double amount, string currency = "USD", string? category = null, string? name = null) + { + Amount = amount; + Currency = currency; + Category = category; + Name = name; + } +} + +/// +/// An individual cost line item or cost breakdown structure node within a cost schedule. +/// Conforms to buildingSMART standard IFCCOSTITEM. +/// +public sealed class IfcCostItem +{ + public string GlobalId { get; set; } = Core.IfcGuid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + + /// Cost Breakdown Structure (CBS) code or MasterFormat / UniFormat code (e.g. "03.30.00"). + public string? CostCode { get; set; } + + /// Unit rate per quantity unit (e.g. $150 per m3 of concrete). + public double? UnitRate { get; set; } + + /// Total quantified volume, area, length, or count. + public double? Quantity { get; set; } + + /// Measurement unit string (e.g. "m3", "m2", "m", "pcs", "kg"). + public string? UnitBasis { get; set; } + + /// Discrete applied values and cost components (Labor, Material, etc.). + public List CostValues { get; } = []; + + /// Hierarchical sub-cost items subordinate to this cost node. + public List SubCostItems { get; } = []; + + /// Building elements assigned to this cost item via IFCRELASSIGNSTOCONTROL. + public List AssignedElements { get; } = []; + + /// Physical quantities linked to this cost item for automated rate-based takeoffs. + public List AssignedQuantities { get; } = []; + + public IfcCostItem() { } + + public IfcCostItem(string name, string? costCode = null, double? unitRate = null, double? quantity = null, string? unitBasis = null) + { + Name = name; + CostCode = costCode; + UnitRate = unitRate; + Quantity = quantity; + UnitBasis = unitBasis; + } + + /// + /// Gets the calculated total cost for this item, either explicitly via cost values, computed from UnitRate * Quantity, or aggregated from sub-items. + /// + public double TotalCost + { + get + { + if (SubCostItems.Count > 0) + { + return SubCostItems.Sum(item => item.TotalCost); + } + + if (CostValues.Count > 0) + { + return CostValues.Sum(v => v.Amount); + } + + if (UnitRate.HasValue && Quantity.HasValue) + { + return UnitRate.Value * Quantity.Value; + } + + return 0.0; + } + } + + /// Adds a nested sub-cost item to this node. + public IfcCostItem AddSubItem(string name, string? costCode = null, double? unitRate = null, double? quantity = null, string? unitBasis = null) + { + var subItem = new IfcCostItem(name, costCode, unitRate, quantity, unitBasis); + SubCostItems.Add(subItem); + return subItem; + } + + /// Adds a discrete cost value breakdown component. + public void AddCostValue(double amount, string currency = "USD", string? category = null, string? name = null) + { + CostValues.Add(new IfcCostValue(amount, currency, category, name)); + } + + /// Assigns an element to this cost item. + public void AssignElement(IfcElement element) + { + ArgumentNullException.ThrowIfNull(element); + if (!AssignedElements.Contains(element)) + { + AssignedElements.Add(element); + } + } + + /// Assigns a physical quantity to this cost item. + public void AssignQuantity(IfcPhysicalQuantity quantity) + { + ArgumentNullException.ThrowIfNull(quantity); + if (!AssignedQuantities.Contains(quantity)) + { + AssignedQuantities.Add(quantity); + } + } +} + +/// +/// A collection of cost items representing a budget, estimate, or cost plan. +/// Conforms to buildingSMART standard IFCCOSTSCHEDULE. +/// +public sealed class IfcCostSchedule +{ + public string GlobalId { get; set; } = Core.IfcGuid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public IfcCostScheduleType ScheduleType { get; set; } = IfcCostScheduleType.CostPlan; + public string? Status { get; set; } + public string Currency { get; set; } = "USD"; + public DateTime UpdateDate { get; set; } = DateTime.UtcNow; + + /// Top-level root cost items in the Cost Breakdown Structure (CBS). + public List RootCostItems { get; } = []; + + public IfcCostSchedule() { } + + public IfcCostSchedule(string name, string currency = "USD", IfcCostScheduleType type = IfcCostScheduleType.CostPlan) + { + Name = name; + Currency = currency; + ScheduleType = type; + } + + /// Enumerates all cost items across the hierarchical CBS tree. + public IEnumerable AllCostItems + { + get + { + var stack = new Stack(RootCostItems); + while (stack.Count > 0) + { + var current = stack.Pop(); + yield return current; + for (int i = current.SubCostItems.Count - 1; i >= 0; i--) + { + stack.Push(current.SubCostItems[i]); + } + } + } + } + + /// Adds a top-level cost item to the schedule. + public IfcCostItem AddCostItem(string name, string? costCode = null, double? unitRate = null, double? quantity = null, string? unitBasis = null) + { + var item = new IfcCostItem(name, costCode, unitRate, quantity, unitBasis); + RootCostItems.Add(item); + return item; + } + + /// Calculates the grand total cost of all root cost items in the schedule. + public double GetTotalCost() => RootCostItems.Sum(item => item.TotalCost); + + /// Finds a cost item by its CBS cost code or name. + public IfcCostItem? FindItem(string codeOrName) + { + ArgumentNullException.ThrowIfNull(codeOrName); + return AllCostItems.FirstOrDefault(item => + string.Equals(item.CostCode, codeOrName, StringComparison.OrdinalIgnoreCase) || + string.Equals(item.Name, codeOrName, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/IfcIO/Model/IfcModelTypes.cs b/src/IfcIO/Model/IfcModelTypes.cs index d64c10d..c5c27a8 100644 --- a/src/IfcIO/Model/IfcModelTypes.cs +++ b/src/IfcIO/Model/IfcModelTypes.cs @@ -225,6 +225,34 @@ public sealed class IfcBuildingStorey public string? GlobalId { get; set; } public List Elements { get; } = []; public List Spaces { get; } = []; + + public IfcElement AddWall(string name, IfcMesh? mesh = null, IfcMaterial? material = null) + { + var wall = new IfcElement { Id = Elements.Count + 1, Name = name, IfcType = "IFCWALL", Mesh = mesh, Material = material }; + Elements.Add(wall); + return wall; + } + + public IfcElement AddSlab(string name, IfcMesh? mesh = null, IfcMaterial? material = null) + { + var slab = new IfcElement { Id = Elements.Count + 1, Name = name, IfcType = "IFCSLAB", Mesh = mesh, Material = material }; + Elements.Add(slab); + return slab; + } + + public IfcElement AddColumn(string name, IfcMesh? mesh = null, IfcMaterial? material = null) + { + var col = new IfcElement { Id = Elements.Count + 1, Name = name, IfcType = "IFCCOLUMN", Mesh = mesh, Material = material }; + Elements.Add(col); + return col; + } + + public IfcElement AddBeam(string name, IfcMesh? mesh = null, IfcMaterial? material = null) + { + var beam = new IfcElement { Id = Elements.Count + 1, Name = name, IfcType = "IFCBEAM", Mesh = mesh, Material = material }; + Elements.Add(beam); + return beam; + } } /// @@ -427,3 +455,29 @@ public IfcSystem(string name, string systemType = "HVAC") : base(name) SystemType = systemType; } } + +/// +/// A spatial group of spaces sharing common thermal, acoustic, fire safety, or security properties. +/// Conforms to buildingSMART standard IFCZONE. +/// +public sealed class IfcZone : IfcGroup +{ + public string? ZoneType { get; set; } + public List Spaces { get; } = []; + + public IfcZone() { } + + public IfcZone(string name, string? zoneType = null) : base(name) + { + ZoneType = zoneType; + } + + public void AddSpace(IfcSpace space) + { + ArgumentNullException.ThrowIfNull(space); + if (!Spaces.Contains(space)) + { + Spaces.Add(space); + } + } +} diff --git a/src/IfcIO/Model/Sequence/IfcSequenceTypes.cs b/src/IfcIO/Model/Sequence/IfcSequenceTypes.cs new file mode 100644 index 0000000..245befe --- /dev/null +++ b/src/IfcIO/Model/Sequence/IfcSequenceTypes.cs @@ -0,0 +1,358 @@ +using System.Globalization; + +namespace IfcIO.Model.Sequence; + +/// +/// Predefined classification for construction schedule tasks conforming to buildingSMART standard IFCTASKTYPEENUM. +/// +public enum IfcTaskType +{ + Construction, + Demolition, + Installation, + Maintenance, + Move, + Operation, + Renovation, + Safety, + UserDefined, + NotDefined +} + +/// +/// Predefined sequence dependency relationship between construction tasks conforming to buildingSMART IFCRELSEQUENCE. +/// +public enum IfcSequenceType +{ + StartToStart, + StartToFinish, + FinishToStart, + FinishToFinish, + UserDefined, + NotDefined +} + +/// +/// Temporal state of a building element during 4D construction schedule simulation. +/// +public enum IfcElementSimulationState +{ + /// Element construction has not yet started. + Unstarted, + + /// Element is actively being constructed/installed at the target simulation date. + InProgress, + + /// Element construction is fully complete. + Constructed, + + /// Element was demolished as part of demolition tasks. + Demolished +} + +/// +/// Captures planned and actual scheduling time attributes for an IFC task. +/// Conforms to buildingSMART IFCTASKTIME (IFC4) and IFCSCHEDULETIMECONTROL (IFC2x3). +/// +public sealed class IfcTaskTime +{ + public string? Name { get; set; } + public TimeSpan? Duration { get; set; } + public string? ScheduleDuration { get; set; } + public DateTime? EarlyStartDate { get; set; } + public DateTime? EarlyFinishDate { get; set; } + public DateTime? LateStartDate { get; set; } + public DateTime? LateFinishDate { get; set; } + public DateTime? PlannedStartDate { get; set; } + public DateTime? PlannedFinishDate { get; set; } + public DateTime? ActualStartDate { get; set; } + public DateTime? ActualFinishDate { get; set; } + + /// Task completion ratio ranging from 0.0 (unstarted) to 1.0 (completed). + public double? Completion { get; set; } + + /// True if this task represents a zero-duration project milestone. + public bool IsMilestone => Duration.HasValue && Duration.Value == TimeSpan.Zero; + + public IfcTaskTime() { } + + public IfcTaskTime(DateTime? plannedStart, DateTime? plannedFinish, double? completion = null) + { + PlannedStartDate = plannedStart; + PlannedFinishDate = plannedFinish; + if (plannedStart.HasValue && plannedFinish.HasValue && plannedFinish.Value >= plannedStart.Value) + { + Duration = plannedFinish.Value - plannedStart.Value; + } + Completion = completion; + } +} + +/// +/// Dependency sequence connection between two tasks conforming to buildingSMART IFCRELSEQUENCE. +/// +public sealed class IfcTaskRelationship +{ + public required IfcTask RelatingProcess { get; init; } + public required IfcTask RelatedProcess { get; init; } + public IfcSequenceType SequenceType { get; set; } = IfcSequenceType.FinishToStart; + public TimeSpan? TimeLag { get; set; } + public string? Description { get; set; } +} + +/// +/// A scheduled construction or operational task within a work schedule conforming to buildingSMART IFCTASK. +/// +public sealed class IfcTask +{ + public string GlobalId { get; set; } = Core.IfcGuid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + + /// Work Breakdown Structure (WBS) identification code (e.g. "1.2.3"). + public string? Identification { get; set; } + + public string? Status { get; set; } + public int? Priority { get; set; } + public IfcTaskType TaskType { get; set; } = IfcTaskType.Construction; + public IfcTaskTime? Time { get; set; } + + /// Hierarchical subtasks subordinate to this task. + public List SubTasks { get; } = []; + + /// Building elements assigned to be constructed or demolished by this task. + public List AssignedElements { get; } = []; + + /// Predecessor sequence dependencies leading into this task. + public List Predecessors { get; } = []; + + /// Successor sequence dependencies leading out of this task. + public List Successors { get; } = []; + + public IfcTask() { } + + public IfcTask(string name, string? identification = null, IfcTaskType taskType = IfcTaskType.Construction) + { + Name = name; + Identification = identification; + TaskType = taskType; + } + + /// Adds a nested subtask under this task. + public IfcTask AddSubTask(string name, string? identification = null, DateTime? start = null, DateTime? finish = null) + { + var subTask = new IfcTask(name, identification, TaskType) + { + Time = (start.HasValue || finish.HasValue) ? new IfcTaskTime(start, finish) : null + }; + SubTasks.Add(subTask); + return subTask; + } + + /// Assigns a physical building element to this task. + public void AssignElement(IfcElement element) + { + ArgumentNullException.ThrowIfNull(element); + if (!AssignedElements.Contains(element)) + { + AssignedElements.Add(element); + } + } + + /// Assigns multiple building elements to this task. + public void AssignElements(IEnumerable elements) + { + ArgumentNullException.ThrowIfNull(elements); + foreach (var el in elements) + { + AssignElement(el); + } + } + + /// Links a successor task with an optional sequence type and lag. + public IfcTaskRelationship AddSuccessor(IfcTask successor, IfcSequenceType sequenceType = IfcSequenceType.FinishToStart, TimeSpan? lag = null) + { + ArgumentNullException.ThrowIfNull(successor); + var rel = new IfcTaskRelationship + { + RelatingProcess = this, + RelatedProcess = successor, + SequenceType = sequenceType, + TimeLag = lag + }; + Successors.Add(rel); + successor.Predecessors.Add(rel); + return rel; + } +} + +/// +/// A collection of scheduled tasks representing a construction or operational schedule. +/// Conforms to buildingSMART standard IFCWORKSCHEDULE. +/// +public sealed class IfcWorkSchedule +{ + public string GlobalId { get; set; } = Core.IfcGuid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public DateTime? StartTime { get; set; } + public DateTime? FinishTime { get; set; } + public TimeSpan? TotalFloat { get; set; } + + /// Root-level tasks in the schedule Work Breakdown Structure (WBS). + public List RootTasks { get; } = []; + + public IfcWorkSchedule() { } + + public IfcWorkSchedule(string name, string? description = null) + { + Name = name; + Description = description; + } + + /// Enumerates all tasks in the schedule across the hierarchical WBS tree. + public IEnumerable AllTasks + { + get + { + var stack = new Stack(RootTasks); + while (stack.Count > 0) + { + var current = stack.Pop(); + yield return current; + for (int i = current.SubTasks.Count - 1; i >= 0; i--) + { + stack.Push(current.SubTasks[i]); + } + } + } + } + + /// Adds a new root task to the schedule. + public IfcTask AddTask(string name, string? identification = null, DateTime? start = null, DateTime? finish = null, IfcTaskType taskType = IfcTaskType.Construction) + { + var task = new IfcTask(name, identification, taskType) + { + Time = (start.HasValue || finish.HasValue) ? new IfcTaskTime(start, finish) : null + }; + RootTasks.Add(task); + return task; + } + + /// Finds a task by its WBS identification code or task name. + public IfcTask? FindTask(string identificationOrName) + { + ArgumentNullException.ThrowIfNull(identificationOrName); + return AllTasks.FirstOrDefault(t => + string.Equals(t.Identification, identificationOrName, StringComparison.OrdinalIgnoreCase) || + string.Equals(t.Name, identificationOrName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Gets all active tasks at the specified target simulation date. + /// + public IReadOnlyList GetActiveTasksAt(DateTime date) + { + var result = new List(); + foreach (var task in AllTasks) + { + var start = task.Time?.ActualStartDate ?? task.Time?.PlannedStartDate; + var finish = task.Time?.ActualFinishDate ?? task.Time?.PlannedFinishDate; + + if (start.HasValue && finish.HasValue && date >= start.Value && date <= finish.Value) + { + result.Add(task); + } + } + return result; + } + + /// + /// Determines the temporal 4D construction state of an element at the specified date. + /// + public IfcElementSimulationState GetElementStateAt(IfcElement element, DateTime date) + { + ArgumentNullException.ThrowIfNull(element); + + // Find all tasks assigned to this element + var assignedTasks = AllTasks.Where(t => t.AssignedElements.Contains(element)).ToList(); + if (assignedTasks.Count == 0) + { + return IfcElementSimulationState.Constructed; + } + + bool anyActive = false; + bool allFinished = true; + bool anyDemolished = false; + + foreach (var task in assignedTasks) + { + var start = task.Time?.ActualStartDate ?? task.Time?.PlannedStartDate; + var finish = task.Time?.ActualFinishDate ?? task.Time?.PlannedFinishDate; + + if (!start.HasValue || !finish.HasValue) + { + continue; + } + + if (date < start.Value) + { + allFinished = false; + } + else if (date >= start.Value && date <= finish.Value) + { + anyActive = true; + allFinished = false; + if (task.TaskType == IfcTaskType.Demolition) + { + anyDemolished = true; + } + } + else if (date > finish.Value) + { + if (task.TaskType == IfcTaskType.Demolition) + { + anyDemolished = true; + } + } + } + + if (anyDemolished) return IfcElementSimulationState.Demolished; + if (anyActive) return IfcElementSimulationState.InProgress; + if (allFinished) return IfcElementSimulationState.Constructed; + + return IfcElementSimulationState.Unstarted; + } + + /// + /// Calculates the overall schedule progress percentage (0.0 to 100.0) at the specified date. + /// + public double GetProgressAt(DateTime date) + { + var tasksWithDates = AllTasks.Where(t => + (t.Time?.ActualStartDate ?? t.Time?.PlannedStartDate).HasValue && + (t.Time?.ActualFinishDate ?? t.Time?.PlannedFinishDate).HasValue).ToList(); + + if (tasksWithDates.Count == 0) return 100.0; + + double completedWeight = 0; + foreach (var task in tasksWithDates) + { + var start = (task.Time?.ActualStartDate ?? task.Time?.PlannedStartDate)!.Value; + var finish = (task.Time?.ActualFinishDate ?? task.Time?.PlannedFinishDate)!.Value; + + if (date >= finish) + { + completedWeight += 1.0; + } + else if (date > start && finish > start) + { + double fraction = (date - start).TotalSeconds / (finish - start).TotalSeconds; + completedWeight += Math.Clamp(fraction, 0.0, 1.0); + } + } + + return Math.Round((completedWeight / tasksWithDates.Count) * 100.0, 2); + } +} diff --git a/tests/IfcIO.Tests/BcfArchiveTests.cs b/tests/IfcIO.Tests/BcfArchiveTests.cs new file mode 100644 index 0000000..861fbe2 --- /dev/null +++ b/tests/IfcIO.Tests/BcfArchiveTests.cs @@ -0,0 +1,65 @@ +using System.Text; +using IfcIO.Bcf; +using IfcIO.Geometry; +using Xunit; + +namespace IfcIO.Tests; + +public class BcfArchiveTests +{ + [Fact] + public void BcfArchive_RoundtripWithSnapshots_PreservesTopicsAndViewpoints() + { + var archive = new BcfArchive("MEP Coordination"); + + var topic = archive.AddTopic("Pipe and Beam Interference", "Main chilled water pipe clashes with structural beam", "Clash", "Critical"); + topic.CreationAuthor = "BIM Manager"; + topic.AssignedTo = "HVAC Lead"; + topic.AddComment("Please lower the pipe run by 150mm.", "Structure Lead"); + + var vp = new BcfViewpoint + { + CameraPosition = new IfcVector3(10.0, 5.0, 3.0), + CameraDirection = new IfcVector3(0.0, 1.0, 0.0), + CameraUpVector = new IfcVector3(0.0, 0.0, 1.0), + FieldOfView = 60.0, + SnapshotData = Encoding.UTF8.GetBytes("FAKE_PNG_BINARY_BYTES_FOR_TEST") + }; + vp.SelectedElementGuids.Add("3$v9a8K2n1_abcDEF0123"); + vp.HiddenElementGuids.Add("1$a2b3c4d5_xyzUVW9876"); + topic.Viewpoints.Add(vp); + + using var ms = new MemoryStream(); + archive.Save(ms); + ms.Position = 0; + + var loaded = BcfArchive.Open(ms); + Assert.Single(loaded.Topics); + var loadedTopic = loaded.Topics[0]; + + Assert.Equal("Pipe and Beam Interference", loadedTopic.Title); + Assert.Equal("Critical", loadedTopic.Priority); + Assert.Equal("BIM Manager", loadedTopic.CreationAuthor); + Assert.Equal("HVAC Lead", loadedTopic.AssignedTo); + Assert.Single(loadedTopic.Comments); + Assert.Equal("Please lower the pipe run by 150mm.", loadedTopic.Comments[0].Comment); + + Assert.Single(loadedTopic.Viewpoints); + var loadedVp = loadedTopic.Viewpoints[0]; + Assert.NotNull(loadedVp.CameraPosition); + Assert.Equal(10.0, loadedVp.CameraPosition.Value.X); + Assert.Single(loadedVp.SelectedElementGuids); + Assert.Equal("3$v9a8K2n1_abcDEF0123", loadedVp.SelectedElementGuids[0]); + Assert.Single(loadedVp.HiddenElementGuids); + Assert.Equal("1$a2b3c4d5_xyzUVW9876", loadedVp.HiddenElementGuids[0]); + Assert.NotNull(loadedVp.SnapshotData); + Assert.Equal("FAKE_PNG_BINARY_BYTES_FOR_TEST", Encoding.UTF8.GetString(loadedVp.SnapshotData)); + + // Test archive search helpers + var foundByElem = loaded.FindTopicsByElement("3$v9a8K2n1_abcDEF0123"); + Assert.Single(foundByElem); + + var foundByStatus = loaded.FindTopicsByStatus("Open"); + Assert.Single(foundByStatus); + } +} diff --git a/tests/IfcIO.Tests/CliExtendedTests.cs b/tests/IfcIO.Tests/CliExtendedTests.cs new file mode 100644 index 0000000..3d4af07 --- /dev/null +++ b/tests/IfcIO.Tests/CliExtendedTests.cs @@ -0,0 +1,97 @@ +using System.Text; +using IfcIO.Bcf; +using IfcIO.Cli; +using IfcIO.Model; +using IfcIO.Model.Cost; +using IfcIO.Model.Sequence; +using IfcIO.Schema; +using Xunit; + +namespace IfcIO.Tests; + +public class CliExtendedTests +{ + private static string CreateModelWithScheduleAndCost() + { + var model = IfcModel.Create("Scheduled Tower", IfcSchema.Ifc4); + var storey = model.AddStorey("L1", 0.0); + var wall = storey.AddWall("Exterior Wall"); + + var schedule = model.AddWorkSchedule("Tower Schedule"); + schedule.StartTime = new DateTime(2026, 1, 1); + schedule.FinishTime = new DateTime(2026, 6, 30); + var task = schedule.AddTask("Construct Walls", "WBS-1.1", new DateTime(2026, 2, 1), new DateTime(2026, 3, 1)); + task.AssignElement(wall); + + var costSchedule = model.AddCostSchedule("Master Budget", "USD", IfcCostScheduleType.Budget); + var costItem = costSchedule.AddCostItem("Wall Masonry", "04.20.00", unitRate: 150.0, quantity: 200.0, unitBasis: "m2"); + costItem.AssignElement(wall); + + string path = Path.Combine(Path.GetTempPath(), $"scheduled_{Guid.NewGuid():N}.ifc"); + model.Save(path); + return path; + } + + [Fact] + public void Cli_Schedule_ExecutesSuccessfullyAndExportsCsv() + { + string ifcPath = CreateModelWithScheduleAndCost(); + string csvPath = Path.Combine(Path.GetTempPath(), $"schedule_{Guid.NewGuid():N}.csv"); + try + { + int code = Program.Main(["schedule", ifcPath, "--sim-date", "2026-02-15", "--export-csv", csvPath]); + Assert.Equal(0, code); + Assert.True(File.Exists(csvPath)); + string csvContent = File.ReadAllText(csvPath); + Assert.Contains("Tower Schedule", csvContent); + Assert.Contains("Construct Walls", csvContent); + } + finally + { + if (File.Exists(ifcPath)) File.Delete(ifcPath); + if (File.Exists(csvPath)) File.Delete(csvPath); + } + } + + [Fact] + public void Cli_Cost_ExecutesSuccessfullyAndExportsCsv() + { + string ifcPath = CreateModelWithScheduleAndCost(); + string csvPath = Path.Combine(Path.GetTempPath(), $"cost_{Guid.NewGuid():N}.csv"); + try + { + int code = Program.Main(["cost", ifcPath, "--export-csv", csvPath]); + Assert.Equal(0, code); + Assert.True(File.Exists(csvPath)); + string csvContent = File.ReadAllText(csvPath); + Assert.Contains("Master Budget", csvContent); + Assert.Contains("Wall Masonry", csvContent); + Assert.Contains("30000", csvContent); // 150 * 200 + } + finally + { + if (File.Exists(ifcPath)) File.Delete(ifcPath); + if (File.Exists(csvPath)) File.Delete(csvPath); + } + } + + [Fact] + public void Cli_Bcf_Read_ExecutesSuccessfully() + { + string bcfPath = Path.Combine(Path.GetTempPath(), $"issues_{Guid.NewGuid():N}.bcfzip"); + try + { + var archive = new BcfArchive("Coordination Check"); + var topic = archive.AddTopic("Clash at Column C3", "MEP duct interferes with structural column", "Clash", "High"); + topic.AddComment("Reviewing reroute options.", "Coordinator"); + archive.Save(bcfPath); + + int code = Program.Main(["bcf", "read", bcfPath]); + Assert.Equal(0, code); + } + finally + { + if (File.Exists(bcfPath)) File.Delete(bcfPath); + } + } +} diff --git a/tests/IfcIO.Tests/CostTests.cs b/tests/IfcIO.Tests/CostTests.cs new file mode 100644 index 0000000..a309156 --- /dev/null +++ b/tests/IfcIO.Tests/CostTests.cs @@ -0,0 +1,70 @@ +using IfcIO.Model; +using IfcIO.Model.Cost; +using IfcIO.Schema; +using Xunit; + +namespace IfcIO.Tests; + +public class CostTests +{ + [Fact] + public void CostSchedule_CanCalculateRollupCosts() + { + var schedule = new IfcCostSchedule("Tower Cost Plan", "USD", IfcCostScheduleType.Budget); + + var concreteWork = schedule.AddCostItem("Concrete Superstructure", "03.00.00"); + var slabItem = concreteWork.AddSubItem("Cast-in-Place Floor Slabs", "03.31.00", unitRate: 250.0, quantity: 120.0, unitBasis: "m3"); + var columnItem = concreteWork.AddSubItem("Structural Columns", "03.32.00", unitRate: 400.0, quantity: 35.0, unitBasis: "m3"); + + Assert.Equal(30000.0, slabItem.TotalCost); // 250 * 120 + Assert.Equal(14000.0, columnItem.TotalCost); // 400 * 35 + Assert.Equal(44000.0, concreteWork.TotalCost); + Assert.Equal(44000.0, schedule.GetTotalCost()); + } + + [Fact] + public void CostItem_CanAssignElementsAndCostValues() + { + var item = new IfcCostItem("Site Earthwork", "02.30.00"); + item.AddCostValue(15000.0, "USD", "Labor"); + item.AddCostValue(25000.0, "USD", "Equipment"); + item.AddCostValue(5000.0, "USD", "Material"); + + var slab = new IfcElement { Id = 1, Name = "Foundation Slab" }; + item.AssignElement(slab); + + Assert.Equal(45000.0, item.TotalCost); + Assert.Single(item.AssignedElements); + Assert.Equal("Foundation Slab", item.AssignedElements[0].Name); + } + + [Theory] + [InlineData(IfcSchema.Ifc4)] + [InlineData(IfcSchema.Ifc2x3)] + public void CostSchedule_StepRoundtrip_PreservesCostItems(IfcSchema schema) + { + var model = IfcModel.Create("Budget Project", schema); + var storey = model.AddStorey("Storey 1", 0.0); + var slab = storey.AddSlab("Ground Slab"); + + var costSchedule = model.AddCostSchedule("Master Budget", "USD", IfcCostScheduleType.Budget); + var costItem = costSchedule.AddCostItem("Slab Package", "03.30.00", unitRate: 300.0, quantity: 50.0, unitBasis: "m3"); + costItem.AssignElement(slab); + + using var ms = new MemoryStream(); + model.Save(ms); + ms.Position = 0; + + var loaded = IfcModel.Open(ms); + Assert.Single(loaded.CostSchedules); + var loadedCs = loaded.CostSchedules[0]; + Assert.Equal("Master Budget", loadedCs.Name); + Assert.Single(loadedCs.RootCostItems); + + var loadedItem = loadedCs.RootCostItems[0]; + Assert.Equal("Slab Package", loadedItem.Name); + Assert.Equal("03.30.00", loadedItem.CostCode); + Assert.Single(loadedItem.AssignedElements); + Assert.Equal(slab.Name, loadedItem.AssignedElements[0].Name); + } +} diff --git a/tests/IfcIO.Tests/IfcApiTests.cs b/tests/IfcIO.Tests/IfcApiTests.cs new file mode 100644 index 0000000..d30ae80 --- /dev/null +++ b/tests/IfcIO.Tests/IfcApiTests.cs @@ -0,0 +1,52 @@ +using IfcIO.Api; +using IfcIO.Model.Cost; +using IfcIO.Model.Sequence; +using IfcIO.Schema; +using Xunit; + +namespace IfcIO.Tests; + +public class IfcApiTests +{ + [Fact] + public void IfcApi_CanBuildFullBimModelWithSequenceAndCost() + { + // 1. Root & Spatial + var model = IfcApi.Root.CreateProject("API Modern Campus", IfcSchema.Ifc4); + var storey1 = IfcApi.Root.CreateStorey(model, "Ground Level", 0.0); + + var wall1 = IfcApi.Root.CreateWall(storey1, "Perimeter Wall 1"); + var slab1 = IfcApi.Root.CreateSlab(storey1, "Ground Slab"); + var col1 = IfcApi.Root.CreateColumn(storey1, "Corner Column"); + var beam1 = IfcApi.Root.CreateBeam(storey1, "Transfer Beam"); + + Assert.Equal(4, storey1.Elements.Count); + + // 2. Spatial Zones + var space = model.AddSpace("Lobby", "101"); + var zone = IfcApi.Spatial.AddZone(model, "Ground Thermal Zone", "Thermal", space); + Assert.Single(zone.Spaces); + + // 3. 4D Sequencing + var schedule = IfcApi.Sequence.AddWorkSchedule(model, "Master Construction Schedule"); + var taskSlab = IfcApi.Sequence.AddTask(schedule, "Pour Slab", "1.1", new DateTime(2026, 1, 1), new DateTime(2026, 1, 10)); + var taskWalls = IfcApi.Sequence.AddTask(schedule, "Erect Walls", "1.2", new DateTime(2026, 1, 11), new DateTime(2026, 1, 20)); + + IfcApi.Sequence.AssignProcess(taskSlab, slab1); + IfcApi.Sequence.AssignProcess(taskWalls, wall1, col1, beam1); + IfcApi.Sequence.AddSequence(taskSlab, taskWalls, IfcSequenceType.FinishToStart); + + Assert.Single(taskSlab.AssignedElements); + Assert.Equal(3, taskWalls.AssignedElements.Count); + Assert.Single(taskSlab.Successors); + + // 4. 5D Cost Planning + var costPlan = IfcApi.Cost.AddCostSchedule(model, "Baseline Cost Plan", "USD", IfcCostScheduleType.Budget); + var itemConcrete = IfcApi.Cost.AddCostItem(costPlan, "Concrete Structures", "03.00.00", unitRate: 350.0, quantity: 80.0, unitBasis: "m3"); + IfcApi.Cost.AssignCostControl(itemConcrete, slab1, wall1, col1, beam1); + + Assert.Equal(28000.0, itemConcrete.TotalCost); + Assert.Equal(28000.0, costPlan.GetTotalCost()); + Assert.Equal(28000.0, model.CalculateTotalCost()); + } +} diff --git a/tests/IfcIO.Tests/SequenceTests.cs b/tests/IfcIO.Tests/SequenceTests.cs new file mode 100644 index 0000000..f438850 --- /dev/null +++ b/tests/IfcIO.Tests/SequenceTests.cs @@ -0,0 +1,115 @@ +using IfcIO.Core; +using IfcIO.Geometry; +using IfcIO.Model; +using IfcIO.Model.Sequence; +using IfcIO.Schema; +using Xunit; + +namespace IfcIO.Tests; + +public class SequenceTests +{ + [Fact] + public void WorkSchedule_CanCreateHierarchyAndSequences() + { + var schedule = new IfcWorkSchedule("Construction Master Schedule"); + + var phase1 = schedule.AddTask("Substructure", "1.0", new DateTime(2026, 1, 1), new DateTime(2026, 3, 31)); + var taskExcavation = phase1.AddSubTask("Bulk Excavation", "1.1", new DateTime(2026, 1, 1), new DateTime(2026, 1, 31)); + var taskFoundations = phase1.AddSubTask("Reinforced Footings", "1.2", new DateTime(2026, 2, 1), new DateTime(2026, 3, 15)); + + var rel = taskExcavation.AddSuccessor(taskFoundations, IfcSequenceType.FinishToStart, TimeSpan.FromDays(1)); + + Assert.Single(schedule.RootTasks); + Assert.Equal(3, schedule.AllTasks.Count()); + Assert.Equal("1.0", phase1.Identification); + Assert.Equal("1.1", taskExcavation.Identification); + Assert.Equal("1.2", taskFoundations.Identification); + Assert.Single(taskExcavation.Successors); + Assert.Single(taskFoundations.Predecessors); + Assert.Equal(IfcSequenceType.FinishToStart, rel.SequenceType); + Assert.Equal(TimeSpan.FromDays(1), rel.TimeLag); + } + + [Fact] + public void WorkSchedule_SimulationState_EvaluatesCorrectly() + { + var schedule = new IfcWorkSchedule("Simulation Schedule"); + var wallElement = new IfcElement { Id = 101, Name = "Bearing Wall 1" }; + + var task = schedule.AddTask( + "Erect Bearing Wall", + "WBS-1", + new DateTime(2026, 6, 1), + new DateTime(2026, 6, 10), + IfcTaskType.Construction); + task.AssignElement(wallElement); + + // Before start date + var stateBefore = schedule.GetElementStateAt(wallElement, new DateTime(2026, 5, 20)); + Assert.Equal(IfcElementSimulationState.Unstarted, stateBefore); + + // During construction + var stateDuring = schedule.GetElementStateAt(wallElement, new DateTime(2026, 6, 5)); + Assert.Equal(IfcElementSimulationState.InProgress, stateDuring); + + // After completion + var stateAfter = schedule.GetElementStateAt(wallElement, new DateTime(2026, 6, 15)); + Assert.Equal(IfcElementSimulationState.Constructed, stateAfter); + + // Active tasks + var active = schedule.GetActiveTasksAt(new DateTime(2026, 6, 5)); + Assert.Single(active); + Assert.Equal(task, active[0]); + + // Progress percentage + double progBefore = schedule.GetProgressAt(new DateTime(2026, 5, 1)); + double progMid = schedule.GetProgressAt(new DateTime(2026, 6, 5)); // halfway + double progAfter = schedule.GetProgressAt(new DateTime(2026, 7, 1)); + + Assert.Equal(0.0, progBefore); + Assert.InRange(progMid, 40.0, 60.0); + Assert.Equal(100.0, progAfter); + } + + [Theory] + [InlineData(IfcSchema.Ifc4)] + [InlineData(IfcSchema.Ifc2x3)] + public void WorkSchedule_StepRoundtrip_PreservesScheduleAndTasks(IfcSchema schema) + { + var model = IfcModel.Create("Hospital Project", schema); + var storey = model.AddStorey("Level 1", 0.0); + var wall = storey.AddWall("Exterior Wall East"); + + var schedule = model.AddWorkSchedule("Hospital Schedule", "Main Construction Plan"); + schedule.StartTime = new DateTime(2026, 1, 1); + schedule.FinishTime = new DateTime(2026, 12, 31); + + var taskA = schedule.AddTask("Foundation Work", "1.0", new DateTime(2026, 1, 1), new DateTime(2026, 3, 31)); + var taskB = schedule.AddTask("Wall Framing", "2.0", new DateTime(2026, 4, 1), new DateTime(2026, 6, 30)); + taskB.AssignElement(wall); + taskA.AddSuccessor(taskB, IfcSequenceType.FinishToStart); + + using var ms = new MemoryStream(); + model.Save(ms); + ms.Position = 0; + + var loaded = IfcModel.Open(ms); + Assert.Single(loaded.WorkSchedules); + var loadedSchedule = loaded.WorkSchedules[0]; + Assert.Equal("Hospital Schedule", loadedSchedule.Name); + Assert.Equal(2, loadedSchedule.RootTasks.Count); + + var loadedTaskA = loadedSchedule.FindTask("1.0"); + var loadedTaskB = loadedSchedule.FindTask("2.0"); + + Assert.NotNull(loadedTaskA); + Assert.NotNull(loadedTaskB); + Assert.Equal("Foundation Work", loadedTaskA.Name); + Assert.Equal("Wall Framing", loadedTaskB.Name); + Assert.Single(loadedTaskB.AssignedElements); + Assert.Equal(wall.Name, loadedTaskB.AssignedElements[0].Name); + Assert.Single(loadedTaskA.Successors); + Assert.Equal(loadedTaskB, loadedTaskA.Successors[0].RelatedProcess); + } +} diff --git a/tests/IfcIO.Tests/SpatialZoneTests.cs b/tests/IfcIO.Tests/SpatialZoneTests.cs new file mode 100644 index 0000000..148abf9 --- /dev/null +++ b/tests/IfcIO.Tests/SpatialZoneTests.cs @@ -0,0 +1,50 @@ +using IfcIO.Model; +using IfcIO.Schema; +using Xunit; + +namespace IfcIO.Tests; + +public class SpatialZoneTests +{ + [Fact] + public void Zone_CanAddSpacesAndManageProperties() + { + var model = IfcModel.Create("Residential Complex"); + var space1 = model.AddSpace("Living Room", "101"); + var space2 = model.AddSpace("Kitchen", "102"); + + var zone = model.AddZone("Thermal Zone 1", "ThermalZone", [space1, space2]); + + Assert.Equal("Thermal Zone 1", zone.Name); + Assert.Equal("ThermalZone", zone.ZoneType); + Assert.Equal(2, zone.Spaces.Count); + Assert.Contains(space1, zone.Spaces); + Assert.Contains(space2, zone.Spaces); + } + + [Theory] + [InlineData(IfcSchema.Ifc4)] + [InlineData(IfcSchema.Ifc2x3)] + public void Zone_StepRoundtrip_PreservesZonesAndSpaces(IfcSchema schema) + { + var model = IfcModel.Create("Office Tower", schema); + var spaceA = model.AddSpace("Server Room", "SR-01"); + var spaceB = model.AddSpace("Electrical Room", "ER-01"); + + var fireZone = model.AddZone("Fire Compartment A", "FireSafety", [spaceA, spaceB]); + + using var ms = new MemoryStream(); + model.Save(ms); + ms.Position = 0; + + var loaded = IfcModel.Open(ms); + Assert.Single(loaded.Zones); + + var loadedZone = loaded.Zones[0]; + Assert.Equal("Fire Compartment A", loadedZone.Name); + Assert.Equal("FireSafety", loadedZone.ZoneType); + Assert.Equal(2, loadedZone.Spaces.Count); + Assert.Contains(loadedZone.Spaces, s => s.Name == "Server Room"); + Assert.Contains(loadedZone.Spaces, s => s.Name == "Electrical Room"); + } +}