diff --git a/.gitignore b/.gitignore index 59eb5b3..1a0fe91 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,7 @@ next-env.d.ts /package-lock.json /native/spacemouse/build/ + +# Local preview launchers +/desktop/local-v090.cjs +/desktop/scripts/launch-v090.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 47142b4..cad90fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ Features, improvements, fixes and compatibility notes for LO2S - OpticMesh. +## [0.9.0 Beta] - 2026-09-10 + +A preview release for testing Technical Plots. Keep a separate project copy before opening it in an older version. The hosted web app remains at v0.7.0. + +- Reviewed the manual and shortcut guide around the Pixel Map → 3D Simulation → Technical Plots workflow. Added illustrated Plots topics for layouts, templates, maps, specifications, framing, text and delivery; refreshed workspace images and corrected outdated saving, export and feature-limit descriptions. + +- Matched Technical Plots scrollbars to the dark inspector styling and kept the page layout stable when notes are expanded. Fixed spaces being intercepted while typing in text areas. + +- Added text-frame formatting controls for bold, italic, underline and alignment, preserved in projects, templates, duplicates and printed/PDF pages. + +- Added a Technical Plots workspace with fixed A3 landscape sheets, editable frame layouts, reusable templates and user-supplied logos and title blocks. +- Added input/output maps, screen detail grids, and specification tables covering pixel pitch, raster, aspect ratios, physical dimensions and mapping coordinates. Long tables continue onto additional sheets. +- Added orthographic and isometric scene views with shaded/wireframe choices and optional stage geometry. +- Added desktop PDF export, an OpticMesh print preview with page navigation and printer selection, project persistence, and Undo/Redo for plot layouts. +- Added cursor-based frame placement, adjustable grid snapping, and drag-and-drop sheet reordering. +- Added paper fonts and frame text sizes, multiline footer notes, and arithmetic fields with the shared adjustment controls and Shift steps of 10. +- Scene frames refresh automatically when their settings change, follow camera names until renamed, and support independent pan and zoom. +- Screen Specifications and Screen Detail use the configured physical panel sizes and panel raster; map labels wrap instead of disappearing on narrow slices. +- Screen Detail reserves space for horizontal and vertical pixel dimensions and wrapped panel notes, preventing overlap with the grid or frame edges. +- Technical Plots uses the shared amber notifications and its own Tools menu label. +- Added Ctrl+D, Duplicate frame and Alt-drag frame copying, preserving settings with independent editing, snapped placement and Undo/Redo. +- Added bottom-right frame resizing, live dimensions, edge/centre alignment guides and equal-spacing snapping with millimetre measurements. +- Input and output maps show each slice’s corresponding X/Y origin, pixel dimensions and physical size. Narrow slices keep wrapped labels inside whenever they fit; numbered callouts are reserved for insufficient space or overlapping/warped geometry. +- Improved scene framing with an immediate drag/zoom preview and a sharp update after interaction. Removed the shifting view-update footer control. +- Changing a plot frame’s camera view now resets its pan and zoom to Fit View; custom titles remain unchanged. +- New view titles use capitalized camera names and can be renamed directly, without an automatic-title checkbox. + ## [0.8.0 Beta] - 2026-09-09 Version 0.8.0 is a beta preview. Version 0.7.0 remains the stable Windows release and hosted web app. diff --git a/README.md b/README.md index f378612..b149c87 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,19 @@ # LO2S - OpticMesh -**Build your LED layout. Check the pixel map. Preview your visuals on stage.** +**Check your pixel map. Simulate your stage. Deliver clear technical plots.** -OpticMesh is a free, open-source companion for visual artists, LED technicians and production designers. Turn a Resolume Advanced Output map into physically sized LED surfaces, place them within an imported stage design, and preview patterns or live visuals from your chosen camera angle. You can also create calibration patterns and export the maps and geometry needed by the rest of your workflow. +OpticMesh is a free, open-source companion for visual artists, LED technicians and production designers. Turn a Resolume Advanced Output map into physically sized LED surfaces, place them within an imported stage design, and preview patterns or live visuals from your chosen camera angle. Prepare branded A3 technical plots from that verified layout, with coordinates, dimensions, panel specifications and scene views. Patterns remains a supporting mode for calibration and content checks. -**New preview: v0.8.0 Beta · Windows x64** +**New preview: v0.9.0 Beta · Windows x64** -[Download v0.8.0 Beta](https://github.com/johnjjdave/opticmesh/releases/tag/v0.8.0) · [Changelog](CHANGELOG.md) +[Download v0.9.0 Beta](https://github.com/johnjjdave/opticmesh/releases/tag/v0.9.0) · [Changelog](CHANGELOG.md) ![OpticMesh 3D workspace with LED screens](public/manual/3d-overview.png) -## One workflow, three workspaces +## One workflow, four workspaces + +The production flow is **Resolume Pixel Map → 3D Simulation → Technical Plots**. Use Patterns whenever you need standalone calibration images. ### Patterns — prepare and calibrate your display @@ -42,15 +44,27 @@ Build a physically scaled preview from your Resolume slices and add an imported - **Live sources:** patterns, video devices, NDI and Spout, with scene-wide or per-slice routing. Native NDI/Spout I/O requires Windows. - **Scene exports:** GLB, glTF, OBJ, MVR mesh packages, STL and USDZ. Preservation of hierarchy, materials and units depends on the format. +### Technical Plots — prepare the handover + +Create fixed **A3 landscape** sheets with your own logo and title block. Combine input/output maps, screen details, specifications, orthographic or isometric stage views, and delivery notes. + +- **Mapping information:** slice names and IDs, input or output coordinates, pixel dimensions, physical sizes, pixel pitch and panel sizes/raster. +- **Page layout:** place, drag, resize and copy frames with grid snapping, alignment guides and millimetre spacing. Reorder sheets and save reusable templates. +- **View framing:** choose the camera and render style, then pan or zoom inside each frame. Each view refreshes when its settings change. +- **Typography:** adjustable fonts and sizes, with bold, italic, underline and alignment for note frames. +- **Delivery:** export PDF or use the A3 print preview. Long specification tables continue onto additional pages. + +Scene views use parallel projection and are labelled **Not to scale**. Review the printed measurements rather than measuring stage geometry from the page. + ## Floating Preview — keep a preview above your show software In 3D mode, **Output → Floating Preview** opens an **always-on-top, camera-only preview** that stays live while you work elsewhere. Drag its top-left handle to position it and the bottom-right corner to resize it. Inside the preview, left-drag orbits, right-drag pans and the wheel zooms. You cannot accidentally edit objects there. -The preview keeps its own camera while receiving scene, material, map-style and live-source updates. It stays open when you switch to Patterns or Pixel Map. **Pause main viewport** stops drawing the main 3D view while the preview continues. +The preview keeps its own camera while receiving scene, material, map-style and live-source updates. It stays open when you switch to Patterns, Pixel Map or Plots. **Pause main viewport** stops drawing the main 3D view while the preview continues. ## Save, share and troubleshoot -Save your work in a **`.lo2s` project**, including imported model geometry. **File → Compile Project** collects the project, Resolume XML and input/output PNG maps into a named folder. +Save your work in a **`.lo2s` project**, including imported model geometry and editable plot sheets. **File → Compile Project** collects the project, Resolume XML and input/output PNG maps into a named folder. Export the finished plots separately as PDF. The Windows app maintains startup and recovery saves. Use **File → Open Recent** to reopen one of your six most recently used projects. OpticMesh asks before replacing unsaved work and offers **Save**, **Quit without saving**, or **Cancel** when closing. Projects reopen on Pattern Generator so you can reconnect live inputs deliberately. @@ -58,11 +72,13 @@ The Windows app maintains startup and recovery saves. Use **File → Open Recent ## Getting started -1. Install the [v0.8.0 Beta](https://github.com/johnjjdave/opticmesh/releases/tag/v0.8.0) +1. Install the [v0.9.0 Beta](https://github.com/johnjjdave/opticmesh/releases/tag/v0.9.0) 2. Create a pattern or import a Resolume Advanced Output XML file in **Pixel Map**. 3. Set your LED product's pixel pitch and check its physical dimensions. 4. Switch to **3D**, import stage geometry if needed, and position your screens. -5. Choose your source, frame the preview, and save a named project before exporting. +5. Choose your source and verify the 3D arrangement. +6. Open **Plots**, prepare your sheets, and check labels, dimensions and view framing. +7. Save a named project, then export the PDF and any required map or 3D files. Each release includes its own searchable, illustrated guide. Open **Guide** or **Help → OpticMesh Manual** inside the app for instructions matching your installed version. Keyboard shortcuts are included in the same guide, with adjustable text size and enlarged screenshots. @@ -70,12 +86,13 @@ Each release includes its own searchable, illustrated guide. Open **Guide** or * Development from v0.8.0 onward focuses on Windows. The hosted web app remains at v0.7.0; it will not receive the new desktop features. -| Capability | Windows v0.8.0 Beta | Hosted web v0.7.0 | +| Capability | Windows v0.9.0 Beta | Hosted web v0.7.0 | | --- | --- | --- | | Patterns, Resolume maps and LED scene layout | Yes | Yes | | Imported stage models and material controls | Yes | Not in the hosted stable release | | NDI / Spout input and output | Yes | No | | Always-on-top Floating Preview | Yes | No | +| Technical Plots, templates and A3 PDF/print | Yes | No | | Managed startup and recovery saves | Yes | No — save projects manually | The Windows x64 installer includes the official NDI Runtime prerequisite when a compatible runtime is absent; NDI Tools is not required. The installer is unsigned. Windows may show an unknown-publisher prompt; use the official release and its `SHA256SUMS.txt` to verify your download. @@ -89,12 +106,12 @@ Documents\OpticMesh\ └── Test Patterns\ ``` -**Projects** holds named projects and recovery files; **Exports** is the default for 3D exports; **Test Patterns** is the default for PNG maps and patterns. Ctrl+S updates a named project, or opens Save As for the startup project. +**Projects** holds named projects and recovery files; **Exports** is the default for 3D exports and Technical Plots PDFs; **Test Patterns** is the default for PNG maps and patterns. Ctrl+S updates a named project, or opens Save As for the startup project. ## Compatibility and scope -- v0.8.0 is a beta preview. Keep a separate copy of projects you need to reopen in v0.7.0; projects containing imported models require v0.8.0 or later. -- Imported models are static geometry. Fixture lighting simulation, animation playback, polygon reduction and Technical Plots are not included in this release. +- v0.9.0 is a beta preview for testing. Keep a separate copy of projects you need to reopen in older versions; Technical Plots requires v0.9.0 or later. +- Imported models are static geometry. Fixture lighting simulation, animation playback, polygon reduction are not included in this release. - Import units and format capabilities matter. OBJ and STL require a source-unit choice; MVR uses its defined units. Review the manual before exchanging files with another application. - 3D NDI/Spout output is 1920 × 1080, targeting 30 fps; achievable performance depends on the scene, sources and system. PNG exports retain their configured native resolution. - Projects and media processing stay local. Desktop update checks contact GitHub; NDI exchanges frames over the network. See the [privacy and release policy](CODE_SIGNING_POLICY.md). diff --git a/app/expression.ts b/app/expression.ts new file mode 100644 index 0000000..c1c7095 --- /dev/null +++ b/app/expression.ts @@ -0,0 +1,58 @@ +export function evaluateExpression(source: string, allowSigned = false): number | null { + const text = source.replace(/[×x]/gi, "*").replace(/÷/g, "/").replace(/,/g, "").trim(); + if (!text || !/^[\d.+\-*/()\s]+$/.test(text)) return null; + let index = 0; + const skip = () => { + while (/\s/.test(text[index] ?? "")) index += 1; + }; + const expression = (): number => { + let value = term(); + while (true) { + skip(); + const op = text[index]; + if (op !== "+" && op !== "-") break; + index += 1; + const next = term(); + value = op === "+" ? value + next : value - next; + } + return value; + }; + const term = (): number => { + let value = factor(); + while (true) { + skip(); + const op = text[index]; + if (op !== "*" && op !== "/") break; + index += 1; + const next = factor(); + value = op === "*" ? value * next : value / next; + } + return value; + }; + const factor = (): number => { + skip(); + if (text[index] === "+" || text[index] === "-") { + const sign = text[index++] === "-" ? -1 : 1; + return sign * factor(); + } + if (text[index] === "(") { + index += 1; + const value = expression(); + skip(); + if (text[index] !== ")") throw new Error("Missing parenthesis"); + index += 1; + return value; + } + const match = text.slice(index).match(/^(?:\d+\.?\d*|\.\d+)/); + if (!match) throw new Error("Expected number"); + index += match[0].length; + return Number(match[0]); + }; + try { + const result = expression(); + skip(); + return index === text.length && Number.isFinite(result) && (allowSigned || result > 0) ? result : null; + } catch { + return null; + } +} diff --git a/app/manual-content.md b/app/manual-content.md index 1a7802d..af54d2f 100644 --- a/app/manual-content.md +++ b/app/manual-content.md @@ -1,8 +1,8 @@ # LO2S - OpticMesh Software Manual -**Manual version:** 0.8.0 +**Manual version:** 0.9.0 -LO2S - OpticMesh is an LED test-pattern, Resolume pixel-map, and physically scaled 3D simulation application designed by LO2S. Use this manual to learn the workspaces, controls, shortcuts, and export workflows. Open it offline through **Help → OpticMesh Manual** or **Guide**; use **Help → Keyboard Shortcuts** for the control reference. +LO2S - OpticMesh is an LED test-pattern, Resolume pixel-map, physically scaled 3D simulation, and technical-plot application designed by LO2S. Use this manual to learn the workspaces, controls, shortcuts, and export workflows. Open it offline through **Help → OpticMesh Manual** or **Guide**; use **Help → Keyboard Shortcuts** for the control reference. ## Contents @@ -16,29 +16,33 @@ In the in-app Manual, choose a section in the left navigation to open its topic 6. [Test Patterns workspace](#6-test-patterns-workspace) 7. [Resolume Pixel Map workspace](#7-resolume-pixel-map-workspace) 8. [3D Simulation workspace](#8-3d-simulation-workspace) -9. [Scene hierarchy and grouping](#9-scene-hierarchy-and-grouping) -10. [Live sources and routing](#10-live-sources-and-routing) -11. [Exporting](#11-exporting) -12. [Keyboard and mouse reference](#12-keyboard-and-mouse-reference) -13. [Numeric fields and mixed values](#13-numeric-fields-and-mixed-values) -14. [Production workflow recommendations](#14-production-workflow-recommendations) -15. [Troubleshooting](#15-troubleshooting) -16. [Projection formats and current limitations](#16-projection-formats-and-current-limitations) -17. [Terminology](#17-terminology) +9. [Technical Plots](#9-technical-plots) +10. [Scene hierarchy and grouping](#10-scene-hierarchy-and-grouping) +11. [Live sources and routing](#11-live-sources-and-routing) +12. [Exporting](#12-exporting) +13. [Keyboard and mouse reference](#13-keyboard-and-mouse-reference) +14. [Numeric fields and mixed values](#14-numeric-fields-and-mixed-values) +15. [Production workflow recommendations](#15-production-workflow-recommendations) +16. [Troubleshooting](#16-troubleshooting) +17. [Projection formats and current limitations](#17-projection-formats-and-current-limitations) +18. [Terminology](#18-terminology) -[Floating Preview controls](#105-floating-preview) +[Floating Preview controls](#115-floating-preview) -[Importing stage models](#stage-model-import-v080) +[Importing stage models](#stage-model-import) --- ## 1. What LO2S - OpticMesh does -LO2S - OpticMesh combines three connected workflows: +LO2S - OpticMesh combines four connected workflows: - **Test Patterns** creates pixel-accurate LED calibration and identification images. - **Resolume Pixel Map** reads Resolume Advanced Output XML, visualizes input and output mappings, applies physical LED information, validates slices, and exports maps. - **3D Simulation** turns the imported slices into physically sized LED screens that can be arranged, curved, grouped, textured, and exported as a 3D scene. +- **Technical Plots** prepares printable pixel-mapping sheets with screen specifications, scene views and your own title block. + +The main production workflow is **Resolume Pixel Map → 3D Simulation → Technical Plots**. Patterns is a supporting mode for calibration and content checks. Confirm the real screen dimensions and 3D arrangement before preparing the plots. The intended production flow is: @@ -49,11 +53,13 @@ Set the real LED pixel pitch and cabinet geometry ↓ Verify input/output maps and XML diagnostics ↓ -Build the physical 3D arrangement +Build and verify the physical 3D arrangement + ↓ +Create Technical Plots from the verified mapping and scene ↓ Save one self-contained .lo2s project ↓ -Export test patterns, maps, or a 3D scene +Export the plots as PDF or print; export maps or a 3D scene as needed ``` ### 1.1 What the application stores locally @@ -70,7 +76,7 @@ Documents\OpticMesh\ ``` - **Projects** contains named projects and managed recovery files. -- **Exports** is the default destination for 3D scene exports. +- **Exports** is the default destination for 3D scene exports and Technical Plots PDFs. - **Test Patterns** is the default destination for exported PNG maps and patterns. Project content and source frames are processed locally. LO2S - OpticMesh does not require uploading project files to an LO2S server. @@ -97,7 +103,7 @@ At first launch, the app creates the `Documents\OpticMesh` workspace and starts The Windows loading window stays visible while your project and its opening view are prepared. Larger stages may take longer to open. If startup fails, use **Retry** or close the loading window. -Use **Open demo project** to learn the interface without preparing a Resolume file. +Use **File → Open Demo** to learn the interface without preparing a Resolume file. --- @@ -105,28 +111,29 @@ Use **Open demo project** to learn the interface without preparing a Resolume fi ### 3.1 Workspaces -Use the left mode rail to switch between three workspaces: +Use the left mode rail to switch between four workspaces: | Workspace | Purpose | |---|---| | **Patterns** | Design standalone LED calibration patterns from physical and raster specifications. | | **Pixel Map** | Import and inspect Resolume Advanced Output maps, edit slice presentation, validate XML, and export maps. | | **3D** | Arrange the mapped slices as physical LED screens in a 3D scene. | +| **Plots** | Create technical sheets from the verified pixel map and 3D arrangement. | -Changing workspace does not create a new project. All three workspaces belong to the same `.lo2s` project. +Changing workspace does not create a new project. All four workspaces belong to the same `.lo2s` project. ### 3.2 Main interface regions - **Top bar:** File, Export, Output, Tools, Help, About, and Undo/Redo. - **Project bar:** project name, save status, notifications, and Studio/Focused layout controls. -- **Left mode rail:** Patterns, Pixel Map (outlined Resolume symbol), 3D, and Guide. +- **Left mode rail:** Patterns, Pixel Map (outlined Resolume symbol), 3D, Plots, and Guide. - **Tools panel:** searchable tools for the active workspace. Focused mode hides this panel to give the viewport more room. - **Tools menu:** the workspace Tools item toggles between Studio and Focused mode. Its background is highlighted while the tools panel is visible; select it again to hide or restore the panel. -- **Central viewport and toolbar:** the pattern, map, or 3D scene with its editing and viewing controls. +- **Central viewport and toolbar:** the pattern, map, 3D scene, or plot sheet with its editing and viewing controls. - **Right inspector:** settings and selection details for the active workspace. -- **Bottom diagnostics:** validation, map changes where available, output status, and performance measurements. +- **Bottom area:** Patterns, Pixel Map and 3D show diagnostics such as validation, output and performance. Plots shows the printed sheet count and paper size. -Patterns inspector tabs are **Setup**, **Overlays**, and **Logo**; Pixel Map uses **Source**, **Geometry**, **Info**, and **Style**; 3D uses **Scene**, **Geometry**, and **Source**. Export commands are in the top **Export** menu. Patterns background and Run test sequence are in **Setup → Pattern presentation**. +Patterns inspector tabs are **Setup**, **Overlays**, and **Logo**; Pixel Map uses **Source**, **Geometry**, **Info**, and **Style**; 3D uses **Scene**, **Geometry**, and **Source**. Plots has sheet and frame properties, including camera view, text and layout settings. Its left panel contains sheets, templates and the project title block. Export commands are in the top **Export** menu. Patterns background and Run test sequence are in **Setup → Pattern presentation**. Interface labels and buttons do not select text when dragged across. Text fields still support selection, copying and editing, including names, searches and coordinate expressions. Scene-object selection and hierarchy range selection work normally. @@ -138,7 +145,7 @@ Notifications appear in a dedicated space in the project bar above the viewport ## 4. Quick-start workflows -### 4.1 Fastest route from Resolume to 3D +### 4.1 From Resolume Pixel Map to 3D and Technical Plots 1. Finish the slice layout in Resolume Advanced Output. 2. Save/export the Advanced Output preset as XML. @@ -148,8 +155,10 @@ Notifications appear in a dedicated space in the project bar above the viewport 6. Confirm slice dimensions and XML validation. 7. Open **3D**. 8. Arrange the screens, add curvature or extrusion, and create groups as needed. -9. Press `Ctrl+S` to save the project. -10. Export the scene as GLB, glTF, OBJ, or MVR. +9. Verify physical sizes, positions and orientation in the 3D scene. +10. Open **Plots**, choose a template, and prepare the maps, specifications and scene views. +11. Review the sheets, then press `Ctrl+S` to save the project. +12. Export the plots as PDF or print them. Export a separate 3D scene if required. ### 4.2 Create and export a standalone test pattern @@ -165,7 +174,7 @@ New projects start with a **10 × 6 m** example wall at **3.9 mm** pixel pitch. ### Save status -The project bar reports the save state: **Manual save only** in the browser, or unsaved/saving/autosaved states when desktop autosave is available. An autosave failure stays visible in the strip with an error indicator until another save changes the state. Hover over a shortened status to read the full message. A manual-save indicator does not mean the current work has been saved. +The project bar reports whether your work is unsaved, saving or autosaved. **Manual save only** means you need to save explicitly. An autosave failure stays visible in the strip with an error indicator until another save changes the state. Hover over a shortened status to read the full message. A manual-save indicator does not mean the current work has been saved. ### 4.3 Continue the latest job @@ -185,10 +194,12 @@ A `.lo2s` file is self-contained. It stores: - per-slice overrides; - 3D transforms, pivots, extrusion, and curvature; - hierarchy groups, subgroups, order, visibility, locks, and collapse state; -- source routing and quality settings (v0.8.0 starts with Pattern Generator on load; see section 10.1); -- camera and viewport state. +- source quality settings; live feeds need reconnecting after opening a project (see [Global sources](#111-global-sources)); +- imported 3D models and their OpticMesh material settings; +- camera and viewport state; +- Technical Plots sheets, frames, text formatting, selected views and title-block details. -Do not edit a `.lo2s` file manually unless you are diagnosing a damaged project and have made a backup. +Keep a named project file with each delivery revision. Use OpticMesh to open and edit it; a PDF is a finished document and cannot restore your editable scene or plot layout. ### 5.2 Startup Project versus named project @@ -196,9 +207,9 @@ LO2S - OpticMesh distinguishes two concepts: | Project state | `Ctrl+S` behaviour | |---|---| -| Managed **Startup Project** | Opens **Save project as…** so you can create a named project. | -| Named project opened with **Load project…** | Atomically overwrites that exact named file. | -| Named project created with **Save project as…** | Atomically overwrites that exact named file. | +| Managed **Startup Project** | Opens **Save As…** so you can create a named project. | +| Named project opened with **Open Project…** | Saves your changes to that named file. | +| Named project created with **Save As…** | Saves your changes to that named file. | Background autosave always writes to the managed startup/recovery files. It never silently overwrites a named project. A named project is overwritten only by an explicit save command. @@ -212,28 +223,30 @@ Save prompts open without selecting a button. Use Tab, Shift+Tab or the arrow ke - **Save project as…:** creates a named `.lo2s` file and makes it active for the session. - **Open Project…:** opens an existing `.lo2s` project and makes that path active. - **Open Recent:** shows the six most recently opened or saved projects in a submenu. The list is retained when you restart OpticMesh. Hover over a project name to see its full location. If a file has moved or been deleted, use Open Project to locate it again. -- **New blank project:** resets the working state after applying a clean project model. -- **Open demo project:** loads a sample scene for exploring the mapping and 3D tools. +- **New Project:** starts a fresh project after the save confirmation. +- **Open Demo:** loads a sample project for exploring Pixel Map, 3D and Technical Plots. - **Reveal Projects folder:** opens `Documents\OpticMesh\Projects`. -**File → Open Demo** opens the sample project in 3D. Switch to Pixel Map to explore its mapping tools. Loading the demo replaces the current working state and disconnects any linked XML file. +**File → Open Demo** opens the sample project in 3D. Switch to Pixel Map to inspect its mapping, then Plots to explore the delivery sheets. Loading the demo replaces the current working state and disconnects any linked XML file. **Before replacing a project:** New Project and Open Demo open a centred confirmation with **Cancel**, **Continue without saving**, and **Save and continue**. Open Project and Open Recent also show this confirmation when the current project has unsaved changes. Cancel or Escape keeps the current project. No action is selected when the prompt opens. Tab, Shift+Tab and the arrow keys move between available actions; Enter or Space activates the focused button. Save and continue saves to the active named project, or opens Save As when a named destination is needed; replacement happens only after a successful save. A cancelled or failed save leaves the project and confirmation open. Controls are temporarily disabled while saving. -The confirmation also appears after autosave: startup recovery follows the current working project and is not a permanent named backup. Project replacement clears the scene Undo history, so Ctrl+Z cannot restore the preceding project after choosing to continue. In browsers that can only download files, verify the downloaded `.lo2s` file before explicitly choosing Continue without saving; requesting a download does not automatically replace the project. +The confirmation also appears after autosave: startup recovery follows the current working project and is not a permanent named backup. Project replacement clears the scene Undo history, so Ctrl+Z cannot restore the preceding project after choosing to continue. ### 5.4 Compile Project -Choose **File → Compile Project…** with a Resolume XML loaded. In Windows, enter the project name in the Save dialog's **File name** field and choose its parent location. OpticMesh creates a new folder using that name. In a browser with folder access, enter the name when prompted, then choose the parent folder. +Choose **File → Compile Project…** with a Resolume XML loaded. Enter the project name in the Save dialog's **File name** field and choose its parent location. OpticMesh creates a new folder using that name. The folder contains: -- **Your Project.lo2s** — the current settings and 3D scene, with the compiled project title. +- **Your Project.lo2s** — the current project, including imported 3D models, scene settings and editable Technical Plots layouts, with the compiled project title. - **Your Project.xml** — the imported Resolume XML, including its original coordinates and spacing. - **Input Map.png** — the full composition raster without selection decorations. - **Output 001 - screen-name.png**, etc. — every screen's full-resolution output map. Numeric prefixes keep names unique. -Compilation preserves the current working project's name, active save path and selection. Existing destination folders are rejected; use a new name. Canceling does not create a folder. The bundled XML represents the imported map; 3D edits are saved in the `.lo2s` file and do not rewrite Resolume coordinates. Live video/NDI/Spout feeds are not copied into this bundle. Browsers without folder access must use the Windows app. +Compilation preserves the current working project's name, active save path and selection. Existing destination folders are rejected; use a new name. Canceling does not create a folder. The bundled XML represents the imported map; 3D edits are saved in the `.lo2s` file and do not rewrite Resolume coordinates. Live video/NDI/Spout feeds are not copied into this bundle. + +Imported models are embedded in the `.lo2s` file; you do not need to copy the original FBX/OBJ separately to reopen the scene. Finished Technical Plots PDFs are not generated by Compile Project: export them from **Plots** and add them to your delivery folder. **Tip:** open the compiled `.lo2s` to continue that copy of the job. The PNG maps are generated at native resolution, regardless of viewport zoom or preview quality. @@ -347,7 +360,7 @@ The linked workflow is available in the installed Windows application. Unlink th - **Input Map** shows slice crops inside the Resolume composition. - **Output Map** shows the selected screen's output-device arrangement. -In v0.8.0, Output Map starts with the first available screen. Choose another screen under **Tools → Map Display → Screen**; switching to Input Map and back retains that choice. Input Map already includes all screens in the composition and needs no screen selector. Output Map displays one real screen at a time; use **Export → All Output Maps** to export every screen as a separate map. +Output Map starts with the first available screen. Choose another screen under **Tools → Map Display → Screen**; switching to Input Map and back retains that choice. Input Map already includes all screens in the composition and needs no screen selector. Output Map displays one real screen at a time; use **Export → All Output Maps** to export every screen as a separate map. **Pattern scope in 3D:** **Across Map** uses the full input composition as one pattern; each LED screen shows the portion at its input-map position. **Per Slice** restarts the pattern on each screen. This scope is retained during the test sequence and in Floating Preview for surfaces using the pattern source. @@ -499,7 +512,7 @@ The editor applies to selected unlocked slices, screen groups, imported models a **Tip:** select a group to edit its shared pivot, or select its individual members to edit their own pivots. Choose a named anchor or use Custom for an exact measured offset. -In v0.8.0, screen groups and imported model items use the same nine-position pad and custom XYZ controls. Offsets are measured from the combined bounds centre along the selected item’s axes. Screen-group offsets use the group’s local metre coordinates; imported-model offsets are shown in metres after source-unit conversion. Presets choose the current bounds and zero the depth offset. Repositioning a pivot preserves all visible geometry and nested child placement. Imported model Coordinates and the viewport gizmo use each item’s pivot for rotation and scaling. Multiple selected model items use a shared gizmo at the average of their pivots, counting selected parents only once. Pivot choices are saved with the project and support Undo/Redo. +screen groups and imported model items use the same nine-position pad and custom XYZ controls. Offsets are measured from the combined bounds centre along the selected item’s axes. Screen-group offsets use the group’s local metre coordinates; imported-model offsets are shown in metres after source-unit conversion. Presets choose the current bounds and zero the depth offset. Repositioning a pivot preserves all visible geometry and nested child placement. Imported model Coordinates and the viewport gizmo use each item’s pivot for rotation and scaling. Multiple selected model items use a shared gizmo at the average of their pivots, counting selected parents only once. Pivot choices are saved with the project and support Undo/Redo. ### 8.5 Extrusion depth @@ -546,26 +559,122 @@ Hidden visibility and locked controls use the same highlighted treatment; normal With 3Dconnexion 3DxWare installed and a SpaceMouse connected, use the main 3D viewport to pan, zoom and orbit with the device. The regular mouse remains available for navigation, selection and editing. Select a model, group or LED slice to keep its centre as the rotation reference in Object mode. Clearing the selection restores automatic rotation-centre behaviour. SpaceMouse navigation changes the camera only; it does not move or rotate scene objects. In Top, Front and Right views, SpaceMouse pans and zooms without rotating the view. In All Views, move the pointer over the pane you want to navigate. -SpaceMouse navigation stops while the main viewport is paused, another workspace or application is active, or a dialog or text field has focus. It is not enabled in Floating Preview or the hosted web app. Open **Tools → 3Dconnexion Settings…** in the Windows 3D workspace to adjust device sensitivity, axis direction and navigation preferences. +SpaceMouse navigation stops while the main viewport is paused, another workspace or application is active, or a dialog or text field has focus. It is not enabled in Floating Preview or Technical Plots. Open **Tools → 3Dconnexion Settings…** in the Windows 3D workspace to adjust device sensitivity, axis direction and navigation preferences. 3D input device development tools and related technology are provided under license from 3Dconnexion. © 3Dconnexion 1992 - 2025. All rights reserved. -## 9. Scene hierarchy and grouping +## 9. Technical Plots + +Use **Resolume Pixel Map → 3D Simulation → Technical Plots** to prepare a delivery set. First check the map, pixel pitch and panel dimensions, then arrange the screens correctly in 3D. The plots describe that project; they cannot correct missing or inaccurate physical information. + +Open **Plots** below 3D in the mode rail. The left panel manages sheets, templates and the project title block. The right inspector edits the sheet and selected frame. The centre shows the page that will be delivered, with temporary editing controls when needed. + +Every page is **A3 landscape, 420 × 297 mm**. **Fit sheet** and the zoom selector change only your viewing size. The paper dimensions remain fixed. Patterns is a separate supporting mode for test images; a plot is the documentation you send with them. + +### 9.1 Choose sheets and content + +Start with **Pixel mapping set**, or use **Add sheet** to build your own set. + +| Sheet | Use it for | +|---|---| +| Input map | Slice locations within the Resolume composition, with input coordinates and dimensions. | +| Output map | Slice locations on one selected output screen, with output coordinates and dimensions. | +| Stage views | Front, Top, side and isometric views of the physical arrangement. | +| Screen detail | One slice's raster, physical size, pixel pitch and panel grid. | +| Screen Specifications | A reference table for the slices, including pitch, panel sizes and map coordinates. | +| Delivery notes | Content instructions or production notes. | +| Blank sheet | A page you populate with your own frames. | + +A **sheet** is one layout in the left list; a **frame** is a content area on that sheet. You can mix frame types on a page. Select a sheet to work on it and edit **Sheet title** in the inspector. Drag sheet rows above or below one another; the insertion line marks their new position. Sheets cannot be grouped or nested. **Duplicate** copies a sheet; **Remove** removes it. Undo restores an accidental removal. + +### 9.2 Place, resize and align frames + +1. Choose a sheet, then select a content type in **Add frame**. +2. Move the outline over the paper. A new frame starts at **180 × 100 mm**; click to place it or press **Escape** to cancel. +3. Turn on **Edit layout** to select and drag frames. The right inspector also lists each frame by title. +4. Drag the selected frame's **bottom-right handle** to resize it. Its top-left corner stays in place. Enter exact X, Y, Width or Height values in millimetres when needed. +5. Choose **Finish layout** to review the page without the editing overlays. + +Under **Page layout**, enable **Snap to grid** and set **Grid spacing** in millimetres. Frame placement, movement and resizing snap to this grid. Nearby edges, centres and equal spacing between frames also provide snap targets, with temporary alignment lines and gap measurements. These guides, selection borders and resize handles never print. Text frames omit the coloured editing title so it cannot cover the note. + +### 9.3 Copy frames + +Select a frame and press **Ctrl+D**, or choose **Duplicate frame**. The copy retains its size, title, content choices, typography, camera and framing. It is placed beside the original when space allows. If no neighbouring space is free, click on the sheet to place the copy or press Escape to cancel. + +In **Edit layout**, hold **Alt** before dragging to place a copy yourself. The same grid and alignment guides apply. Each copy is independent: for example, duplicate a Screen detail frame and choose another **Screen slice** without changing the original. Each duplication is one Undo step. Ctrl+D does not duplicate frames while you are typing in a field. + +### 9.4 Templates and branding + +A template stores your sheet layout, frame settings and branding for reuse. Enter **Template title**, then choose **Save template to library**. Choosing a saved preset fills this field with that preset's name. Keep the name to update that library entry, or enter a new name to save a separate template. **Import** and **Export** exchange template files with another computer. + +Applying a template replaces the current plot layout; Undo restores it. Templates do not contain the imported stage model or Resolume map. After applying one to another project, review every **Output screen** and **Screen slice** choice, since those bindings follow their position in the project's lists. + +Use **Project title block → Add your logo…** for a PNG or JPG logo. Fill in the project title, company, author, revision, date and footer note. An empty logo area stays blank; OpticMesh does not add advertising to the printed page. The footer note accepts spaces and line breaks and wraps on the paper. Drag the bottom of its input box downwards for more writing room; this does not enlarge the printed footer. + +Save the `.lo2s` project to keep the layout and title block with the scene. A template is a reusable layout, not a replacement for saving the project. + +### 9.5 Input and output maps + +Input maps use the composition's **top-left origin**. Output maps use the selected output screen's **top-left origin**; choose **Output screen** in Frame settings. Add or duplicate an Output frame for each output you need to document. + +Every slice is identified by its name and ID, the corresponding X/Y origin in pixels, pixel width and height, and physical width and height in metres. Screen IDs follow imported Resolume screen order, then slice order within each screen. The same IDs connect the maps to the specification table. + +Labels wrap inside a slice when space permits. Small, overlapping or warped slices may use matching numbered callouts below the map. Review these at a comfortable page zoom before exporting. If labels cannot fit, enlarge the frame or reduce **Frame text size**. Map coordinates and raster sizes come from Resolume; verify pitch and panel geometry in Pixel Map to get meaningful physical dimensions. Do not measure metres from the printed pixel-map layout. + +### 9.6 Screen details and specifications + +A **Screen detail** frame shows the selected slice's raster and aspect ratio, nominal and effective pitch, surface size, panel dimensions in millimetres, and pixels per panel. Choose the slice under **Frame settings → Screen slice**. Its checker cells follow the configured panel grid, including per-slice overrides. For example, a 500 × 500 mm cabinet using 128 × 128 pixels has an effective pitch of 3.90625 mm. Partial panels remain partial at the screen edges. + +Dimension labels sit outside the grid, with space before the panel note and frame border. Enlarge the frame or reduce its text size if the measurements cannot fit. Use the PNG exports in Patterns or Pixel Map for pixel-accurate test images; the grid on an A3 sheet is a reference drawing. + +**Screen Specifications** lists all slices with raster, aspect ratio, nominal/effective pitch, physical size, panel dimensions and raster, input origins and output coordinates. Physical size describes the screen surface, not its projected width in a stage view. + +Long specification tables continue onto additional printed pages automatically. Their number depends on the frame size, text size and slice count. The left list keeps the original sheet; use the continuation-page selector above the paper to inspect the extra pages. PDF and print include them automatically. Other frames on the specification sheet repeat on each continuation, so keep that layout simple. + +### 9.7 Camera views and framing + +Choose **Front, Back, Left, Right, Top, Bottom**, or an upper/lower **isometric** view. These are parallel views. Choose **Neutral shaded** or **Wireframe**, and whether to **Include imported models**. New frames use the camera name as their title. Change **Title** to give one a custom name. + +Changing a camera, render style or framing refreshes that view automatically. **Refresh views** updates the scene views from the current project. Export also updates outdated views before creating the pages. The main 3D camera stays unchanged. + +Use **Adjust view framing** to drag the composition inside its frame and scroll to zoom. A responsive preview follows the gesture; the sharp image updates when you stop. **View zoom** and **View pan X/Y** provide numeric control. **Fit view** resets the composition. Choose **Finish framing** or press Escape when done. + +Changing **Camera view** fits the new angle and resets that frame's zoom and pan. It keeps the frame's position and dimensions. Scene views are labelled **Not to scale** because you can freely zoom and frame them; use the stated dimensions, not measurements taken from the printed image. + +### 9.8 Text and typography + +Choose the paper font and text size under **Page layout**. Changing the page text size also updates its frames; use **Frame text size** afterwards to adjust an individual frame. Check the final page after changing typography, since larger text can need more space. + +Selecting a text frame reveals **Text formatting** with **Bold, Italic, Underline**, and **Left/Centre/Right** alignment. These controls apply to the entire note body, not individual selected words. They leave other frame types and the frame title unchanged. Text formatting is retained when saving, duplicating, exporting PDF or printing. + +Enter note content in **Text**, using Enter for a new line. Keep delivery instructions concise and check that they fit inside the frame. The bottom title-block **Footer note** is separate from a text frame and is intended for short notes. + +### 9.9 PDF and print + +With Plots active, choose **Export → Export A3 PDF…** for the complete sheet set, including specification continuations. Select a filename and destination. Editing guides and application controls are excluded. Review any amber notification before retrying a blocked export. + +**Export → Print…** opens OpticMesh's page preview. Browse with the arrows, choose an installed printer, select all sheets or the current preview sheet, and set the number of copies. **Export PDF** is also available here. Pages remain A3 landscape; the printer and paper settings must support that size. + +Before issuing a set, check the project title and revision, correct input/output screen choices, every slice label, panel sizes and pitch, useful camera framing, and note legibility. Save the `.lo2s` project alongside the final PDF. **Compile Project** includes the editable plot layout in the project file; export the PDF separately when you need a finished document in the delivery folder. + +--- + +## 10. Scene hierarchy and grouping Hierarchy icons distinguish the item types: a **monitor** represents a Resolume screen, an **outlined cube** represents an editable slice/object, and the **group symbol** represents a group. Grouped slices keep their object icon. -### 9.1 Hierarchy objects +### 10.1 Hierarchy objects The hierarchy can contain: -- ungrouped slices at scene root; -- parent groups; -- slices inside groups; -- nested subgroups. +- Resolume screen containers and their original slices; +- imported models and mesh parts; +- editable groups containing slices, models or both; +- nested editable subgroups. -Group transforms are true parent transforms. Child slices keep editable local transforms while their world transforms are derived from their parent chain. +Moving, rotating or scaling a group also moves its children. You can still select and adjust a child within that group. Resolume screen containers accept only their own original slices; imported models belong in editable groups. -### 9.2 Selecting groups +### 10.2 Selecting groups - Click a group to select it. - `Ctrl`-click toggles individual groups in a multi-group selection. @@ -576,18 +685,18 @@ If a selected parent and one of its selected descendants are transformed togethe **Imported-model range selection:** click the first item, scroll to the last and Shift-click it to select every model row in between. Scrolling and switching inspector tabs retain the anchor. Ctrl-click toggles one item; Ctrl+Shift-click adds a range. Expand any branches whose children you want included before selecting the range. -### 9.3 Creating a group +### 10.3 Creating a group 1. Select one or more ungrouped slices. 2. Choose **Group**. 3. A new parent group is created around the combined geometry centre. 4. The slices are converted to local child transforms without changing their visible world placement. -### 9.4 Renaming +### 10.4 Renaming Double-click a group name to rename it. Press `Enter` to accept the edit or `Escape` to leave editing. A single click selects the group; it does not begin renaming. -### 9.5 Reordering and reparenting +### 10.5 Reordering and reparenting Drag directly in the hierarchy: @@ -600,11 +709,11 @@ Drag directly in the hierarchy: Reparenting converts transforms so the object does not visibly jump in world space. -### 9.6 Collapse and expand +### 10.6 Collapse and expand All groups use the same four-square icon, whether they contain screen slices or imported model parts. Use the separate disclosure arrow beside a group to collapse or expand its children. The expanded state is saved in the project. Searching the hierarchy temporarily reveals matching descendants. -### 9.7 Visibility and locking +### 10.7 Visibility and locking - The eye control shows or hides an object in the 3D scene. - The lock control enables or prevents editing. @@ -612,13 +721,13 @@ All groups use the same four-square icon, whether they contain screen slices or - When multiple groups are selected, clicking the visibility or lock control on one selected group applies that state to every selected group. - When multiple slices are selected, clicking a slice visibility or lock control applies the state to the selected slices. -### 9.8 Ungrouping +### 10.8 Ungrouping -Choose **Ungroup** to dissolve selected groups while preserving world placement. In v0.8.0, direct Resolume slice children return to their original screen containers, retaining their current position, rotation and scale. Surviving nested editable groups and imported items retain a valid scene parent. +Choose **Ungroup** to dissolve selected groups while preserving world placement. direct Resolume slice children return to their original screen containers, retaining their current position, rotation and scale. Surviving nested editable groups and imported items retain a valid scene parent. --- -### Stage model import (v0.8.0) +### Stage model import In **3D → Tools → 3D Models**, choose **Import Model…**. Use **Choose files** for a model and its companion files, or **Choose folder** to retain asset-folder paths. The left side contains the main-model selector, scale/orientation and hierarchy controls. Press **Read model** to inspect the larger preview on the right, with separate Width (X), Height (Y), Depth (Z), mesh and triangle readouts. **Cancel** and **Import into scene** stay in the bottom action bar. Supported formats are available in an expandable list. Cancelling the file or folder picker returns to this dialog, keeping any prepared preview and settings. You can then choose a different file or folder. The dialog’s **Cancel** or **×** button, or Escape while the dialog is active, closes the import without changing the scene. @@ -693,7 +802,7 @@ In 3D mode, **Delete** removes selected imported models, groups or parts. It nev ### Imported-model storage and limits -Imported geometry is saved inside the project, so reopening does not depend on the original model file. Projects containing imported models require v0.8.0 or later; keep a separate copy if you also use v0.7.0. Compile Project includes the imported geometry in the project file when a Resolume map is present. Source texture files, animation and other application-specific editing features are not retained. +Imported geometry is saved inside the project, so reopening does not depend on the original model file. Use v0.9.0 or later to retain both imported models and Technical Plots; keep a separate copy before working with older releases. Compile Project includes the imported geometry in the project file when a Resolume map is present. Source texture files, animation and other application-specific editing features are not retained. There is no polygon-count gate. Selected/expanded file data has a 512 MiB import working budget; compressed embedded model data is limited to 150 MiB in total across all imported models, within a 192 MiB desktop project limit. The remaining space accommodates the Resolume XML, logos and project settings. Actual memory use can exceed file size. For heavy files, export only the stage geometry needed for screen alignment. Polygon reduction is not part of this version. @@ -703,7 +812,7 @@ Select LED slices or a screen group and open **Material** in the bottom panel. T A slice inherits the closest parent-group material/display unless overridden. In a mixed group, group material edits reach both inheriting imported model surfaces and LED extrusions. Explicit child overrides take priority; set **Material** or **Display** to **Inherit** to restore that property's parent setting. Inheritance remains live through nested groups and saved projects. A group edit changes its inherited children together; multiple selections show common or mixed values. **Material → Inherit** removes a custom override. Empty and locked selections cannot be edited. Undo/Redo, project saving and supported scene exports retain extrusion materials. Double-click defaults are Diffuse 100%, Metallic 28%, Roughness 78% and Specular 100%, with the original dark extrusion colour as the inherited default. -The reflection environment is a viewport aid and is not bundled with scene exports. Receiving applications supply their own reflection environment. Projects containing extrusion material overrides use schema 4 and require v0.8.0 Beta or later. +The reflection environment is a viewport aid and is not bundled with scene exports. Receiving applications supply their own reflection environment. Use the current OpticMesh version when exchanging projects with material overrides or Technical Plots. In **Scene Display**, choose **HDRI1**, **HDRI2** or **HDRI3** to compare reflection looks. The three buttons appear side by side in both the left Tools panel and the Scene inspector; either row changes the same setting. The chosen look also updates Floating Preview, is saved with the project, and supports Undo/Redo. New projects start with **HDRI1**. **Tip:** lower Roughness to reveal reflection detail, or raise it for a softer surface. These fixed environments affect shaded imported models and LED extrusions; they do not change the background, floor, wireframes or LED display content, and do not reflect live imagery or moving scene objects. The HDRI files are not embedded in projects or scene exports. @@ -711,33 +820,33 @@ In **Scene Display**, choose **HDRI1**, **HDRI2** or **HDRI3** to compare reflec **Reset transform** in Coordinates restores the recorded transform baseline while retaining the chosen pivot. New imports record their initial placement; model items from older projects without a recorded baseline use their placement before their first edit. Descendant reset baselines follow parent transformations. Reset and numeric edits respect locks and support Undo/Redo. -## 10. Live sources and routing +## 11. Live sources and routing -### 10.1 Global sources +### 11.1 Global sources The 3D Simulation can use: | Source | Description | |---|---| | **Pattern Generator** | Uses the current LO2S - OpticMesh test pattern at native quality. | -| **Video Devices** | Uses a webcam, capture device, or another browser-accessible camera source. | +| **Video Devices** | Uses a webcam, capture device, or another available camera source. | | **NDI** | Discovers and receives an NDI source through the Windows native bridge. | | **Spout** | Discovers and receives a local Spout sender through the Windows native bridge. | NDI and Spout are desktop-only features. -**v0.8.0 startup:** Opening a project or restoring the latest session starts all LED slices on **Pattern Generator**, with per-slice source routing reset to **Inherit global source**. The 3D inspector opens on **Scene**. To resume a live preview, open **Source**, select the global feed, then choose and connect the sender or device. Reapply individual slice routing where needed. Source quality, scene geometry and materials remain saved. +**On startup:** Opening a project or restoring the latest session starts all LED slices on **Pattern Generator**, with per-slice source routing reset to **Inherit global source**. The 3D inspector opens on **Scene**. To resume a live preview, open **Source**, select the global feed, then choose and connect the sender or device. Reapply individual slice routing where needed. Source quality, scene geometry and materials remain saved. When a selected live feed has no connected image, its LED display faces show solid black. Connected images retain their normal colours; this does not change the scene background or extrusion materials. -### 10.2 Source quality +### 11.2 Source quality - **Low Latency** prioritizes responsiveness and a latest-frame workflow. - **High Quality** prioritizes source detail and may require more decoding, transfer, and GPU resources. Use Low Latency while arranging complex scenes. Switch to High Quality when inspecting texture detail or creating a final visual review. -### 10.3 Per-slice source overrides +### 11.3 Per-slice source overrides Selected slices can: @@ -749,29 +858,29 @@ Selected slices can: If selected slices have different source routing, the inspector displays a mixed state. **Reset selected routing** returns the selection to the global source. -### 10.4 Performance status +### 11.4 Performance status Open **Performance** below the viewport for live measurements. The panel contains readings and their status labels; the colour key, measurement guidance and troubleshooting tips are documented here. The panel refreshes twice per second and can be collapsed with the diagnostic-bar chevron. At smaller window sizes, scroll the panel to see all metrics. | Metric | Meaning | | --- | --- | -| UI FPS | Live animation-callback cadence while the panel is open and visible. Measures UI scheduling even when the scene is idle; it is not the number of rendered/presented 3D frames or video/output FPS. Display refresh and browser scheduling limit this value. | -| UI frame · peak | Longest UI callback interval in the last second. Spikes can indicate main-thread stalls or browser/OS scheduling delays. | +| UI FPS | Interface responsiveness while the panel is visible. It stays active when the scene is idle and differs from viewport or video-output FPS. | +| UI frame · peak | Longest interface frame time in the last second. Repeated spikes can correspond to visible stutters. | | CPU · app | Combined Electron-process CPU usage, normalized across all logical processors. Requires desktop; external NDI/Spout helper processes are excluded. The first reading needs a sampling baseline. | | CPU · system | Whole-machine CPU usage, including other applications and external helpers. Requires desktop. | | App memory · private | Combined private committed memory of Electron processes on Windows. This is not exclusively resident RAM and does not include external helpers or VRAM. | | RAM · system | Physical RAM used / total for the whole machine. Requires desktop. | | GPU / VRAM | Per-device NVIDIA GPU utilization and dedicated memory used / total, including all applications. Requires the desktop app and supported NVIDIA driver telemetry; unavailable readings are not reported as zero. | | Viewport redraws / s | Actual viewport redraws per second, measured over the last second. A still scene displays **Idle** because no redraw is needed; a hidden window or manually paused main viewport displays **Paused**. This is separate from monitor refresh rate and incoming video FPS. | -| CPU render · avg / peak | Average and slowest viewport drawing time during the last second. Includes JavaScript drawing and WebGL submission, but excludes waiting for GPU completion and unrelated application work. | +| CPU render · avg / peak | Average and slowest CPU time spent drawing the viewport during the last second. Higher values mean more drawing work. | | Last CPU draw | Most recent draw duration, retained while idle. | -| Last GPU draw | Latest valid GPU time for a complete 3D viewport draw, including all visible panes. Optional hardware/browser support is required; unsupported, invalidated and lost-context measurements are labelled explicitly. Canvas 2D GPU timing is unavailable. | +| Last GPU draw | Latest measured GPU drawing time across visible 3D panes. Not available on every system or for the 2D canvas. | | Render size | Actual 3D drawing-buffer dimensions or 2D preview raster. An interactive working raster can differ from native export dimensions. | | Scene triangles | Total triangles in LED and imported meshes, including hidden items. Each mesh instance is counted once; camera movement, selection outlines and All Views do not change this total. Geometry changes, imports and deletions update it. Floor, grid and gizmos are excluded. | | Draw calls / Drawn triangles | Calls and triangles submitted for the latest complete 3D viewport frame. Camera culling changes which objects are drawn; selection outlines and additional panes can draw geometry again. This measures rendering work, not model size. Output capture is excluded. | | Textures / geometries | Renderer-tracked allocated resource counts; these are counts, not memory bytes. | -**Reading the counters:** UI FPS continues during an idle view while **Viewport redraws / s** correctly shows **Idle**. OpticMesh renders on demand; an idle reading does not mean poor performance. Monitoring does not force scene redraws. CPU/memory update about once per second and NVIDIA counters about every 2–3 seconds while this panel is visible. Closing it stops polling; hidden windows pause monitoring. Missing, stale or unsupported values are explicit. Localhost in a normal browser shows UI/render measurements; native CPU, RAM and GPU readings require a desktop development session or compatible desktop version. +**Reading the counters:** UI FPS continues while **Viewport redraws / s** may show **Idle** when the scene is still. That is normal. CPU/memory readings update about once per second and supported NVIDIA readings about every 2–3 seconds. Missing or unsupported values are labelled; they do not mean zero load. **Performance colours:** readings use off-white **Normal**, warm amber **Attention**, and soft coral **High pressure**, with a text label beside the value. RAM and VRAM include a percentage alongside used/total memory. GPU utilization and VRAM are rated independently. @@ -789,15 +898,15 @@ If drawing is heavy, hide unnecessary models or pause the main viewport while us **Camera redraws:** The Viewport redraws reading increases while you orbit, pan or zoom. An idle reading is normal when the view is still and no animated content is playing. -**Finding a bottleneck:** compare the same camera movement or source playback across readings. Falling UI FPS with a high UI frame peak can indicate main-thread work, though OS/browser scheduling can also cause it. High GPU utilization with longer GPU draw times can indicate GPU load; high VRAM occupancy indicates memory pressure, but includes other applications. Low overall CPU usage does not rule out one busy CPU core. Compare system usage with app usage before attributing a slowdown to the current scene. No single reading proves the cause. +**Finding a bottleneck:** compare the same camera movement or source playback across readings. Falling UI FPS with a high UI frame peak can indicate main-thread work, though other system activity can also cause it. High GPU utilization with longer GPU draw times can indicate GPU load; high VRAM occupancy indicates memory pressure, but includes other applications. Low overall CPU usage does not rule out one busy CPU core. Compare system usage with app usage before attributing a slowdown to the current scene. No single reading proves the cause. Native-source status can report source resolution, displayed frame rate, conversion time, copy time, canvas time, and overwritten/missed frames. Performance depends on source resolution, codec, network conditions, GPU, scene geometry, and the number of active feeds. --- -### 10.5 Floating Preview +### 11.5 Floating Preview -In **3D**, open **Output → Floating Preview** to create a camera-only preview. This menu item is greyed out in Patterns and Pixel Map. It starts from the editor's current perspective camera and then navigates independently. Scene geometry, visibility, source textures, floor, grid, and background continue to update from the editor. Objects cannot be selected, moved, rotated, scaled, grouped, or deleted in this preview. +In **3D**, open **Output → Floating Preview** to create a camera-only preview. This menu item is greyed out outside 3D. It starts from the editor's current perspective camera and then navigates independently. Scene geometry, visibility, source textures, floor, grid, and background continue to update from the editor. Objects cannot be selected, moved, rotated, scaled, grouped, or deleted in this preview. | Action | Control | | --- | --- | @@ -808,17 +917,27 @@ In **3D**, open **Output → Floating Preview** to create a camera-only preview. | Resize the window | Drag the **bottom-right corner** only | | Close the preview | Small **top-right ×**, **Escape** while preview is focused, or toggle **Output → Floating Preview** off in 3D | -**Pause the main viewport:** With Floating Preview open, choose **Output → Pause main viewport**. The editor shows a paused notice and **Resume viewport** button while the undocked preview keeps its camera and live updates. The pause applies to the main 3D view, including All Views; Pixel Map and Patterns remain editable. Closing Floating Preview automatically resumes the main view. This setting is temporary and is not saved in projects. +**Pause the main viewport:** With Floating Preview open, choose **Output → Pause main viewport**. The editor shows a paused notice and **Resume viewport** button while the undocked preview keeps its camera and live updates. The pause applies to the main 3D view, including All Views; Pixel Map, Patterns and Plots remain editable. Closing Floating Preview automatically resumes the main view. This setting is temporary and is not saved in projects. -Pausing stops main-viewport draws and suspends its repeated media/pattern texture updates when no native output needs them. Active NDI/Spout captures continue rendering their own frames. Scene resources remain allocated for quick resumption, so this reduces rendering work rather than releasing all GPU memory. A static viewport already renders on demand; the largest saving is with changing sources or test sequences. +Pausing can reduce rendering load while Floating Preview continues. It does not unload the scene or stop active NDI/Spout output. The benefit is greatest with moving sources or running test sequences. The Windows application opens a borderless window above other applications, without a bright outer outline. The top-left move handle, top-right close button and bottom-right resize handle remain visible. Windows start at 640 × 360 and can be resized freely down to 240 × 160; the camera adapts to the window's aspect ratio without stretching models. Changing the window size does not change model dimensions or native stream resolution. -**Tips:** Use the move handle to position the window, then drag inside the preview to choose the camera angle. Make scene edits in the main editor and watch them update in the preview. Floating Preview can remain open alongside NDI or Spout; its camera does not change their editor-camera view. Closing the preview leaves native streaming running. **Output → OFF** stops both. Switching to Pixel Map or Patterns keeps the preview open with its camera, position and size intact, so map style and logo edits can be previewed live. The Floating Preview menu item is disabled in Patterns and Pixel Map. To close an existing preview from those workspaces, use its × button or Escape while it is focused, or choose Output → OFF. Closing or reloading the editor also closes the preview. Reopening starts from the editor's perspective camera again; window placement and the preview camera are temporary and are not saved in the project. +**Tips:** Use the move handle to position the window, then drag inside the preview to choose the camera angle. Make scene edits in the main editor and watch them update in the preview. Floating Preview can remain open alongside NDI or Spout; its camera does not change their editor-camera view. Closing the preview leaves native streaming running. **Output → OFF** stops both. Switching to Pixel Map, Patterns or Plots keeps the preview open with its camera, position and size intact, so map style and logo edits can be previewed live. The Floating Preview menu item is disabled outside 3D. To close an existing preview from those workspaces, use its × button or Escape while it is focused, or choose Output → OFF. Closing or reloading the editor also closes the preview. Reopening starts from the editor's perspective camera again; window placement and the preview camera are temporary and are not saved in the project. -## 11. Exporting +## 12. Exporting -### 11.1 PNG export +Choose the deliverable for the recipient: + +| Deliverable | Where to create it | +|---|---| +| Pixel-accurate test image or map | **Export** in Patterns or Pixel Map. | +| 3D scene for another application | **Export** in 3D. | +| Printable mapping and stage documentation | **Export A3 PDF…** or **Print…** in Plots. | +| Editable OpticMesh project plus XML and PNG maps | **File → Compile Project…**. Export the PDF separately. | + + +### 12.1 PNG export - **Export PNG / Export Current PNG:** exports the current Test Pattern or current map view. - **Export Input PNG:** exports the full Resolume composition input map. @@ -827,7 +946,7 @@ The Windows application opens a borderless window above other applications, with Desktop PNG exports are routed to `Documents\OpticMesh\Test Patterns` unless another location is explicitly selected by the workflow. -### 11.2 3D scene export formats +### 12.2 3D scene export formats | Format | Use | |---|---| @@ -835,8 +954,8 @@ Desktop PNG exports are routed to `Documents\OpticMesh\Test Patterns` unless ano | **glTF Package** | ZIP package containing glTF scene resources. | | **OBJ Package** | ZIP package with metre-based coordinates. Import at scale 1 with source units set to metres. | | **MVR 1.5** | Scene meshes for compatible entertainment-production workflows. | -| **STL (v0.8.0)** | Binary world-positioned triangle geometry. No materials or hierarchy; coordinates are written in metres, so select metres when importing into unitless consumers. | -| **USDZ (v0.8.0)** | Packaged scene geometry and supported appearance. Compatibility with receiving applications should be checked. | +| **STL** | Binary world-positioned triangle geometry. No materials or hierarchy; coordinates are written in metres, so select metres when importing into unitless consumers. | +| **USDZ** | Packaged scene geometry and supported appearance. Compatibility with receiving applications should be checked. | 3D exports include: @@ -849,22 +968,32 @@ Desktop PNG exports are routed to `Documents\OpticMesh\Test Patterns` unless ano 3D exports exclude the viewport floor, grid, camera, selection outlines, and transform gizmos. -In v0.8.0, visible imported stage geometry is included alongside screens and can also be exported without a Resolume map. Display wireframe is an editor option; model geometry exports as a neutral shaded surface. OBJ includes its neutral material definition. Import availability does not imply export support for the same format: FBX, 3DS, DAE, standalone GDTF and separate USD/USDA/USDC export are not provided. +visible imported stage geometry is included alongside screens and can also be exported without a Resolume map. Display wireframe is an editor option; model geometry exports as a neutral shaded surface. OBJ includes its neutral material definition. Import availability does not imply export support for the same format: FBX, 3DS, DAE, standalone GDTF and separate USD/USDA/USDC export are not provided. **OBJ units:** one exported coordinate unit equals one metre, with Y as the up axis. OBJ has no standard physical-unit declaration, so select **metres** in the receiving application's import settings. The package README and OBJ comments identify this convention, but cannot configure the importer automatically. Choosing millimetres makes the geometry 1,000 times too small; choosing centimetres makes it 100 times too small. Changing only the receiving application's displayed unit does not correct an import-scale mismatch. Reimport using the correct source unit. This applies equally to LED screens and imported stage models, regardless of their original source units. -### 11.3 Export validation +### Technical Plots export + +Prepare and review the A3 sheets in **Plots**, then use **Export → Export A3 PDF…** or **Print…**. Specification continuation pages are included. Page zoom does not affect output size, and editing guides are excluded. See [PDF and print](#99-pdf-and-print) for the delivery checks and printer options. + +### 12.3 Export validation Export may be blocked when a curved screen's extrusion depth is physically invalid for its radius. Reduce extrusion or curvature, then export again. --- -## 12. Keyboard and mouse reference +## 13. Keyboard and mouse reference -### 12.1 Current keyboard shortcuts +### 13.1 Current keyboard shortcuts | Shortcut | Scope | Action | |---|---|---| +| `Ctrl+D` | Technical Plots | Duplicate the selected frame with all its settings. Inactive while editing a field. | +| `Alt`-drag | Technical Plots, Edit layout | Drag a copy of a frame with the current snapping and alignment guides. | +| `Ctrl+Z` | Technical Plots | Undo a plot edit when not typing in a field. | +| `Ctrl+Y` / `Ctrl+Shift+Z` | Technical Plots | Redo a plot edit when not typing in a field. | +| `Shift` + Up/Down or mouse wheel | Technical Plots numeric fields | Increase or decrease by 10. Without Shift, change by 1. | +| `Escape` | Technical Plots | Cancel pending frame placement or finish view framing. In a numeric field, restore the uncommitted value. | | `Ctrl+S` | Application | Save. Opens Save As for Startup Project; overwrites the active named project. | | `Ctrl+Z` | 3D Simulation | Undo the latest 3D history operation. Works while a parameter input has focus. | | `Ctrl+Shift+Z` | 3D Simulation | Redo. | @@ -887,7 +1016,7 @@ Export may be blocked when a curved screen's extrusion depth is physically inval | `Ctrl`-click | Pixel Map / hierarchy / 3D | Toggle an item in a multi-selection. | | `Shift`-click | Pixel Map / hierarchy / 3D | Select a range between hierarchy rows; in the viewport, extend or toggle the current selection. | | `Ctrl`-drag | 3D viewport | Add visible model items or screens inside a marquee, using the active selection type. | -| `Space` + left-drag | 2D canvas | Pan the pattern or map. Does not activate while typing in an input. | +| `Space` + left-drag | 2D canvas | Pan the pattern or map. Does not activate while typing in text or numeric fields. | | `Enter` | Numeric or group-name field | Commit the entered value or finish group renaming. | | `Escape` | Editable field | Cancel/revert supported field editing or finish group renaming without continuing the edit mode. | | `Tab` / `Shift+Tab` / arrow keys | Save or project-replacement prompt | Move focus between available actions. No action starts selected. | @@ -900,7 +1029,7 @@ The 3D letter shortcuts accept lowercase and uppercase. Letter and F1–F5 view **Tip:** with the 3D workspace active and no field, menu, or dialog open, press F to fit the entire visible scene. Use S to focus selected objects; in All Views, hover the pane you want to focus before pressing S. F reframes all panes, straightens the horizon and keeps your selection. -### 12.2 Mouse controls +### 13.2 Mouse controls | Control | 2D canvas | 3D viewport | |---|---|---| @@ -912,7 +1041,7 @@ The 3D letter shortcuts accept lowercase and uppercase. Letter and F1–F5 view | Wheel over numeric field | Adjust value | Adjust value | | `Shift` + wheel over numeric field | Larger adjustment | Larger adjustment | -### 12.3 All Views navigation +### 13.3 All Views navigation Press **F1** for Perspective, **F2** for Top, **F3** for Right, **F4** for Front, or **F5** for All Views. View shortcuts use the function keys without Ctrl, Alt, or Shift. On keyboards with media controls, hold **Fn** if required to send the function key. Switching views preserves object placement and selection. @@ -927,9 +1056,9 @@ Choose **All Views** in the 3D toolbar. The panes are Perspective (upper left), --- -### 12.4 Floating Preview controls +### 13.4 Floating Preview controls -Floating Preview is a camera-only preview in v0.8.0 Beta. Its controls are separate from the editor's object tools. +Floating Preview is a camera-only preview. Its controls are separate from the editor's object tools. | Control | Action | | --- | --- | @@ -942,9 +1071,28 @@ Floating Preview is a camera-only preview in v0.8.0 Beta. Its controls are separ Object-selection, transform, grouping, delete, undo/redo, and project shortcuts do not run while the preview is focused. Continue scene editing in the main editor. -## 13. Numeric fields and mixed values +### 13.5 Technical Plots controls -### 13.1 Arithmetic entry +| Control | Action | +|---|---| +| Click a sheet row | Open that sheet. | +| Drag a sheet row | Reorder it at the insertion line; no nesting. | +| Add frame, then click the page | Place the outline at the cursor. Escape cancels. | +| Drag a frame in Edit layout | Move it with the current grid and alignment snapping. | +| Drag the bottom-right handle | Resize the selected frame. | +| Alt-drag / Ctrl+D | Copy a frame with its settings. | +| Left-drag in Adjust view framing | Pan the view inside its frame. | +| Wheel in Adjust view framing | Zoom that view. | +| Escape / Finish framing | Leave view framing. | +| Wheel over the page outside view framing | Scroll the paper; use Fit sheet or its zoom selector for page magnification. | +| Up/Down or wheel over a numeric field | Adjust by 1; hold Shift to adjust by 10. | +| Ctrl+Z / Ctrl+Y or Ctrl+Shift+Z | Undo / redo plot edits when not typing. | + +Text fields retain normal editing keys. Bold, Italic and Underline use the buttons in **Text formatting**; no dedicated formatting keyboard shortcuts are assigned. + +## 14. Numeric fields and mixed values + +### 14.1 Arithmetic entry Supported numeric fields accept arithmetic expressions containing: @@ -970,7 +1118,7 @@ Press `Enter` or leave the field to commit. Invalid, non-finite, or out-of-range Click the double-chevron control at the right of a numeric field to open its Increase and Decrease buttons. These buttons have larger click targets; they retain the field’s existing increment, limits, and undo behavior. Use Tab to reach the buttons and Enter or Space to activate them. Escape closes the controls and returns focus to the adjustment button; clicking elsewhere also closes them. Direct entry and wheel adjustment remain available. Hover over a numeric value or shortened project label to recover its full text. -### 13.2 Signed fields +### 14.2 Signed fields Position, rotation, and curvature fields accept negative values. Physical size, resolution, cabinet size, and pixel pitch must remain positive. @@ -980,7 +1128,7 @@ Scrolling over a numeric value adjusts it without moving the surrounding panel. The existing step size and Shift adjustment continue to apply where supported. When no numeric value is selected, hovering a numeric control still allows wheel adjustment without simultaneously scrolling its panel. -### 13.3 Mixed selections +### 14.3 Mixed selections When several selected objects have different values, the relevant field shows a dash, blank mixed state, or **Multiple values**. Enter a value to apply it across the selected objects. @@ -992,7 +1140,7 @@ Double-click a slider's thumb or track to restore that setting's built-in defaul The reset uses the same scope as dragging: selected-slice controls update the selected slices, while global controls update the project setting. It changes only that parameter; disabled sliders remain disabled. The readout and preview update immediately, and the reset is saved with the project. Existing 3D undo/redo also applies to slider resets. -### 13.4 Rotation continuity +### 14.4 Rotation continuity Rotation values can pass ±90° and continue beyond a full revolution. Numeric fields retain a continuous angle close to the previous value while you rotate. @@ -1000,7 +1148,7 @@ Multiple-turn values are retained where possible so `450°` remains meaningful i --- -## 14. Production workflow recommendations +## 15. Production workflow recommendations ### Top toolbar menus @@ -1017,20 +1165,20 @@ For keyboard use, Tab to a toolbar button and press Enter or Space to toggle its - In All Views, use Front to align horizontal/vertical placement, Top for horizontal/depth placement, and Right for vertical/depth placement. Watch Perspective to check the combined result. - Zoom into the working pane around the cursor without disturbing the other camera views. Use Focus for the selection in the last-used pane; reserve Fit Scene for reframing all cameras. -### 14.1 Before importing +### 15.1 Before importing - Give Resolume screens and slices clear, production-safe names. - Confirm composition resolution. - Remove unused or accidental slices. - Save a clean Advanced Output XML revision. -### 14.2 Establish physical truth first +### 15.2 Establish physical truth first The most important rule is: **set the real pixel pitch before judging 3D size or spacing**. Do not choose a preset simply because it is close. Enter the manufacturer's exact nominal pitch when needed. Confirm cabinet dimensions and resulting panel raster. -### 14.3 Build in stages +### 15.3 Build in stages 1. Import and validate XML. 2. Set pixel pitch and cabinet geometry. @@ -1040,10 +1188,11 @@ Do not choose a preset simply because it is close. Enter the manufacturer's exac 6. Group only after the basic arrangement is trustworthy. 7. Add curvature and extrusion. 8. Connect live sources. -9. Review the arrangement and source routing, then save a named project. -10. Export deliverables. +9. Review the arrangement and source routing. +10. Prepare Technical Plots from that verified scene; check screen choices, coordinates, dimensions and page framing. +11. Save a named project and export the final PDF, maps and any required 3D scene files. -### 14.4 Grouping strategy +### 15.4 Grouping strategy - Group screens according to real scenic or structural assemblies. - Use nested groups for a complete structure containing subassemblies. @@ -1051,7 +1200,7 @@ Do not choose a preset simply because it is close. Enter the manufacturer's exac - Avoid selecting both a parent and its child unless you intentionally need hierarchy-range selection; the app prevents double transformation, but a cleaner selection is easier to understand. - Confirm group placement and alignment before final export. -### 14.5 Save strategy +### 15.5 Save strategy - Let Startup Project autosave protect work in progress. - Create a named file for meaningful project milestones. @@ -1061,9 +1210,9 @@ Do not choose a preset simply because it is close. Enter the manufacturer's exac --- -## 15. Troubleshooting +## 16. Troubleshooting -### 15.1 All screens overlap in 3D +### 16.1 All screens overlap in 3D Check the following: @@ -1073,7 +1222,7 @@ Check the following: 4. The project was not loaded from an older build with invalid transforms. 5. Use **Reset adaptive layout** on the affected slices if custom transforms are no longer wanted. -### 15.2 Physical spacing looks wrong +### 16.2 Physical spacing looks wrong - Confirm the central/primary screen is the automatic anchor at `X = 0`. - Confirm exact pitch rather than relying on a nearby preset. @@ -1081,11 +1230,11 @@ Check the following: - Inspect whether selected slices have individual overrides. - Remember that the Resolume pixel map provides a 2D relationship, not stage depth; Z placement must be created in the 3D scene. -### 15.3 Selected fields show a dash +### 16.3 Selected fields show a dash The selected objects contain different values. Enter a value to unify them, or select one object to inspect its exact setting. -### 15.4 Rotation changes X or Z near 90° +### 16.4 Rotation changes X or Z near 90° Rotation fields should remain continuous as you drag. If another axis changes unexpectedly, note the following when reporting the issue: @@ -1094,11 +1243,11 @@ Rotation fields should remain continuous as you drag. If another axis changes un - record the values before and after crossing 90°; - report whether the change occurred during one drag or after repeated drags. -### 15.5 Rotation wraps after one revolution +### 16.5 Rotation wraps after one revolution For an exact multi-turn result, enter the desired degree value directly. Rotation fields accept values beyond one revolution. -### 15.6 Group or slice jumps when reparented +### 16.6 Group or slice jumps when reparented Reparenting is designed to preserve the world transform. If a jump occurs, record: @@ -1109,7 +1258,7 @@ Reparenting is designed to preserve the world transform. If a jump occurs, recor Save a copy of the `.lo2s` file before attempting to reproduce the problem. -### 15.7 NDI source is not found +### 16.7 NDI source is not found - Use the installed Windows application. - Open Windows **Installed apps** and confirm **NDI Runtime** is present. The OpticMesh installer supplies it automatically when needed; rerun or repair the installer if it was cancelled. @@ -1119,14 +1268,14 @@ Save a copy of the `.lo2s` file before attempting to reproduce the problem. - Confirm the sender is active and its source name is visible to another NDI application. - Try Low Latency quality first. -### 15.8 Spout source is not found +### 16.8 Spout source is not found - Confirm the Spout sender is running on the same Windows computer. - Confirm GPU compatibility between sender and receiver. - Scan again after starting the sender. - Close software that may hold the sender exclusively. -### 15.9 3D interaction becomes slow +### 16.9 3D interaction becomes slow - Use Low Latency source quality. - Disconnect live video while arranging geometry. @@ -1135,11 +1284,11 @@ Save a copy of the `.lo2s` file before attempting to reproduce the problem. - Close other GPU-heavy applications. - Use the Performance panel to identify which interactions take the longest to draw. -### 15.10 3D export is blocked +### 16.10 3D export is blocked Read the notification. If a curved screen has invalid extrusion, reduce depth or curvature until the body fits within the available radius. -### 15.11 Startup project does not restore +### 16.11 Startup project does not restore - Open **Reveal Projects folder**. - Check for `Startup Project.lo2s` and `Startup Project.previous.lo2s`. @@ -1149,13 +1298,25 @@ Read the notification. If a curved screen has invalid extrusion, reduce depth or --- -## 16. Projection formats and current limitations +### Technical Plots: missing labels, views or pages + +- **Missing or cramped labels:** enlarge the frame or reduce Frame text size. Check numbered callouts below input/output maps and read the amber notification. +- **Wrong physical dimensions:** confirm pitch and panel geometry in Pixel Map, then verify the 3D arrangement before reissuing the plots. +- **Wrong slice or output after applying a template:** choose the correct Screen slice or Output screen in each affected frame. +- **View too small or cropped:** use Adjust view framing or Fit view. Changing Camera view also fits the new angle. +- **More specification pages than sheet rows:** use the continuation-page selector; the full set is included in PDF and print. +- **Notes cut short:** enlarge the text frame, reduce text size or shorten the note. Expanding the footer input gives typing room but does not enlarge the printed footer. +- **Print unavailable or failed:** check that a printer is installed and supports A3 landscape, or export PDF for printing elsewhere. + +--- + +## 17. Projection formats and current limitations -### 16.1 Choosing a view +### 17.1 Choosing a view Use the 3D toolbar to choose **Perspective**, **Top**, **Right**, **Front**, or **All Views**. Narrower windows show a camera selector; wider windows show individual view buttons. Each pane in All Views has independent camera controls and displays the same scene. -### 16.2 Projection formats +### 17.2 Projection formats The Patterns viewport toolbar separates the projection format from Pattern Fill: @@ -1169,17 +1330,18 @@ While you adjust a Dome control, the preview temporarily uses a lower resolution Dome typography is proportional to the selected native raster, so its apparent label size remains consistent when moving between 1K, 2K, 4K, 6K, and 8K. Ring weight is selected as **Thin**, **Medium**, or **Bold**. -### 16.3 Current limitations +### 17.3 Current limitations - The 3D Simulation remains Beta and is not a photoreal render engine. -- A material editor is unavailable. -- Editable custom group-axis placement is not yet implemented. -- Multiple groups can be selected together; mixed group-and-slice selection is unavailable. +- Technical Plots currently uses A3 landscape pages; other paper sizes and fixed drawing scales are not available. +- Stage views in plots are images in a parallel projection, labelled Not to scale; maps and text remain vector content in PDF. +- Text formatting applies to a complete note body; individual-word formatting is not available. +- Technical Plots documents pixel mapping and screen geometry. LED processor routing, sending-card layouts and lighting patch are outside its scope. - Large scenes, curvature, and multiple live sources can reduce responsiveness. Use the Performance panel to monitor viewport drawing. --- -## 17. Terminology +## 18. Terminology | Term | Meaning | |---|---| @@ -1201,5 +1363,9 @@ Dome typography is proportional to the selected native raster, so its apparent l | **Spout** | Windows GPU texture-sharing system used between applications on one computer. | | **GLB/glTF** | Modern 3D scene formats supporting meshes, transforms, and UV data. | | **MVR** | Entertainment-production scene exchange format. | +| **Technical Plot** | A printable sheet documenting mapping, physical screen information and scene views. | +| **Frame** | A content area placed on a Technical Plots sheet. | +| **Template** | A reusable plot layout and branding setup; it does not include the project scene. | +| **Isometric view** | A parallel three-axis view showing the arrangement without perspective convergence. | NDI® is a registered trademark of Vizrt NDI AB. Visit [ndi.video](https://ndi.video/) for official NDI technology, licensing, runtime, and tools information. diff --git a/app/manual-dialog.tsx b/app/manual-dialog.tsx index 0f7849b..82ac84a 100644 --- a/app/manual-dialog.tsx +++ b/app/manual-dialog.tsx @@ -23,13 +23,21 @@ for(const line of manual.replace(/\r/g,"").split("\n")){ } const welcome:Page={id:"welcome",title:"Welcome to OpticMesh",parent:null,text:manual.slice(manual.indexOf("LO2S - OpticMesh is an LED"),manual.indexOf("## Contents")).trim()}; const allPages=[welcome,...pages],chapters=pages.filter(p=>!p.parent); -const shortcutPage=pages.find(p=>p.title.startsWith("12."))!; +const shortcutPage=pages.find(p=>p.title.endsWith("Keyboard and mouse reference"))!; const assetBase=(import.meta as ImportMeta & {env?:{BASE_URL?:string}}).env?.BASE_URL||"/"; const illustrations:Record={ + "8-3d-simulation-workspace":{src:`${assetBase}manual/simulation.png`,alt:"3D workspace with the demo screens, transform tools and scene hierarchy",caption:"Arrange the mapped screens and verify their physical placement before preparing Technical Plots."}, + "9-technical-plots":{src:`${assetBase}manual/plots-workspace.png`,alt:"Technical Plots with sheet list, four stage views and sheet properties",caption:"Manage sheets on the left, review the A3 page in the centre, and edit its frames on the right."}, + "92-place-resize-and-align-frames":{src:`${assetBase}manual/plots-layout.png`,alt:"Four scene frames in Edit layout with selection border and resize handle",caption:"Select a frame in Edit layout; drag its body to move it or its bottom-right handle to resize."}, + "94-templates-and-branding":{src:`${assetBase}manual/plots-templates.png`,alt:"Template picker, labelled template title field and library actions",caption:"Choose a saved layout, name your template, then save it to the library or export it."}, + "96-screen-details-and-specifications":{src:`${assetBase}manual/plots-screen-detail.png`,alt:"Screen detail showing panel grid, pixel dimensions and panel specifications",caption:"The grid represents the selected slice’s configured panels; the measurements are kept outside it."}, + "97-camera-views-and-framing":{src:`${assetBase}manual/plots-framing.png`,alt:"Selected stage-view frame with camera, style, zoom and pan controls",caption:"Adjust view framing changes the composition inside one frame, without moving the scene."}, + "98-text-and-typography":{src:`${assetBase}manual/plots-text.png`,alt:"Text formatting section with bold, italic, underline and alignment controls",caption:"These controls appear only for text frames and apply to the whole note body."}, + "99-pdf-and-print":{src:`${assetBase}manual/plots-print.png`,alt:"A3 print preview with page navigation, printer choice and copies",caption:"Review each page before printing. The printer shown is an example; choose your installed device."}, "31-workspaces":{src:`${assetBase}manual/patterns.png`,alt:"Patterns workspace with tools on the left, the test pattern in the centre and wall settings on the right",caption:"Patterns: tools, canvas and settings share one workspace."}, "73-pixel-pitch-and-physical-scale":{src:`${assetBase}manual/pixel-map.png`,alt:"Resolume Pixel Map workspace showing named slices and physical scale settings",caption:"Inspect the mapping and set the real pixel pitch before arranging the screens in 3D."}, - "91-hierarchy-objects":{src:`${assetBase}manual/hierarchy.png`,alt:"Scene hierarchy showing screen containers, LED slices and an expanded imported stage model with Platform and Screen parts",caption:"Screens, slices and imported model parts use the same hierarchy controls."}, - "stage-model-import-v080":{src:`${assetBase}manual/import-model.png`,alt:"Import 3D model dialog with a simple platform and screen, metre units, hierarchy toggle and dimension readouts",caption:"Check the model, units and dimensions before importing."}, + "101-hierarchy-objects":{src:`${assetBase}manual/hierarchy.png`,alt:"Scene hierarchy showing screen containers, LED slices and an expanded imported stage model with Platform and Screen parts",caption:"Screens, slices and imported model parts use the same hierarchy controls."}, + "stage-model-import":{src:`${assetBase}manual/import-model.png`,alt:"Import 3D model dialog with a simple platform and screen, metre units, hierarchy toggle and dimension readouts",caption:"Check the model, units and dimensions before importing."}, "led-extrusion-materials":{src:`${assetBase}manual/material.png`,alt:"Material controls including display, diffuse colour, diffuse intensity, metallic, roughness and specular",caption:"Material controls affect LED sides and backs while the display face keeps its source image."} }; function Markdown({text,navigate}:{text:string;navigate:(id:string)=>void}){ diff --git a/app/page.tsx b/app/page.tsx index 7599bba..b7d2b37 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,9 @@ "use client"; +import { evaluateExpression } from "./expression"; +import TechnicalPlots from "./technical-plots"; +import { defaultPlotDocument, readPlotDocument, type PlotSource } from "./technical-plots-data"; +import type { SceneExportOptions } from "./scene-export"; import RecentProjectsMenu, { type RecentProject } from "./recent-projects-menu"; import { reflectionPreset, type ReflectionPreset } from "./studio-environment"; import { ProjectEncoder, ProjectSnapshotTracker, type ProjectPatch } from "./project-encoder"; @@ -51,7 +55,7 @@ type PatternType = "metric" | "cabinet" | "color" | "gray" | "pixel"; type ProjectionFormat = "planar" | "dome" | "cubemap" | "equirectangular" | "cylindrical"; type DomeBackground = "black" | "grayscale" | "spectrum" | "uv" | "transparent" | "custom"; type DomeRingWeight = "thin" | "medium" | "bold"; -type WorkspaceMode = "patterns" | "resolume" | "simulation"; +type WorkspaceMode = "patterns" | "resolume" | "simulation" | "plots"; type MapView = "input" | "output"; type ControlTab = "setup" | "overlays" | "info" | "deco" | "logo" | "scene" | "sources"; type FullscreenMode = "fit" | "actual"; @@ -528,7 +532,7 @@ function patternStyleFromConfig(config: PatternConfig): PatternStyle { // Local iteration identifies the active milestone without changing release metadata. const LOCAL_PREVIEW = process.env.NODE_ENV === "development"; -const DISPLAY_VERSION = LOCAL_PREVIEW ? "0.8.0" : packageMetadata.version.replace(/^v/i, "").replace(/-beta.*$/i, ""); +const DISPLAY_VERSION = LOCAL_PREVIEW ? "0.9.0" : packageMetadata.version.replace(/^v/i, "").replace(/-beta.*$/i, ""); const BUILD_BADGE = "Beta"; const BUILD_DESCRIPTION = LOCAL_PREVIEW ? "Local development preview" : "Beta"; const TransformSelectionScope = createContext(""); @@ -630,64 +634,6 @@ function normalizeSourceOverrides(value: unknown): Record).map(([id, source]) => [id, source === "inherit" ? "inherit" : normalizeSimulationSource(source)])); } -function evaluateExpression(source: string, allowSigned = false): number | null { - const text = source.replace(/[×x]/gi, "*").replace(/÷/g, "/").replace(/,/g, "").trim(); - if (!text || !/^[\d.+\-*/()\s]+$/.test(text)) return null; - let index = 0; - const skip = () => { - while (/\s/.test(text[index] ?? "")) index += 1; - }; - const expression = (): number => { - let value = term(); - while (true) { - skip(); - const op = text[index]; - if (op !== "+" && op !== "-") break; - index += 1; - const next = term(); - value = op === "+" ? value + next : value - next; - } - return value; - }; - const term = (): number => { - let value = factor(); - while (true) { - skip(); - const op = text[index]; - if (op !== "*" && op !== "/") break; - index += 1; - const next = factor(); - value = op === "*" ? value * next : value / next; - } - return value; - }; - const factor = (): number => { - skip(); - if (text[index] === "+" || text[index] === "-") { - const sign = text[index++] === "-" ? -1 : 1; - return sign * factor(); - } - if (text[index] === "(") { - index += 1; - const value = expression(); - skip(); - if (text[index] !== ")") throw new Error("Missing parenthesis"); - index += 1; - return value; - } - const match = text.slice(index).match(/^(?:\d+\.?\d*|\.\d+)/); - if (!match) throw new Error("Expected number"); - index += match[0].length; - return Number(match[0]); - }; - try { - const result = expression(); - skip(); - return index === text.length && Number.isFinite(result) && (allowSigned || result > 0) ? result : null; - } catch { - return null; - } -} function ExpressionField({ label, value, suffix, onCommit, scopeKey = "global", integer = false, min = integer ? 1 : 0.0001, max = Number.POSITIVE_INFINITY }: { label: string; value: number | null; suffix: string; onCommit: (value: number) => void; scopeKey?: string; integer?: boolean; min?: number; max?: number }) { const [draft, setDraft] = useState(value === null ? "" : String(value)); @@ -2335,11 +2281,23 @@ export default function Home({ uiVersion = "v070" }: { uiVersion?: "legacy" | "v [startupProjectStatus, setStartupProjectStatus] = useState("Preparing autosave…"); const [pendingOpenProject,setPendingOpenProject]=useState(null); const [pendingReplacement,setPendingReplacement]=useState<"new"|"demo-scene"|"demo-map"|null>(null); + const [plotDocument, setPlotDocument] = useState(defaultPlotDocument); + const plotExportRef = useRef<((print?: boolean) => void) | null>(null); + const [plotHistory, setPlotHistory] = useState<{ past: ReturnType[]; future: ReturnType[] }>({ past: [], future: [] }); + const updatePlotDocument = useCallback((next: ReturnType) => { setPlotHistory(h => ({ past: [...h.past, plotDocument].slice(-60), future: [] })); setPlotDocument(next); }, [plotDocument]); + const undoPlot = useCallback(() => { const previous = plotHistory.past.at(-1); if (!previous) return; setPlotHistory({ past: plotHistory.past.slice(0, -1), future: [plotDocument, ...plotHistory.future] }); setPlotDocument(previous); }, [plotHistory, plotDocument]); + const redoPlot = useCallback(() => { const next = plotHistory.future[0]; if (!next) return; setPlotHistory({ past: [...plotHistory.past, plotDocument], future: plotHistory.future.slice(1) }); setPlotDocument(next); }, [plotHistory, plotDocument]); + useEffect(() => { + if (workspaceMode !== 'plots') return; + const listener = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (target?.closest('input,textarea,select,[contenteditable=true],dialog') || document.querySelector('dialog[open]') || !(event.ctrlKey || event.metaKey)) return; if (event.key.toLowerCase() === 'z') { event.preventDefault(); if (event.shiftKey) redoPlot(); else undoPlot(); } else if (event.key.toLowerCase() === 'y') { event.preventDefault(); redoPlot(); } }; + window.addEventListener('keydown', listener); return () => window.removeEventListener('keydown', listener); + }, [workspaceMode, undoPlot, redoPlot]); + const [activeProjectPath, setActiveProjectPath] = useState(null); const [helpTopic, setHelpTopic] = useState<"manual" | "shortcuts" | null>(null); const [compilingProject, setCompilingProject] = useState(false); const compileInFlight = useRef(false); - const [renderMetrics] = useState(() => ({ patterns: new RenderPerformance(), resolume: new RenderPerformance(), simulation: new RenderPerformance() })); + const [renderMetrics] = useState(() => ({ patterns: new RenderPerformance(), resolume: new RenderPerformance(), simulation: new RenderPerformance(), plots: new RenderPerformance() })); const [v070InspectorTab, setV070InspectorTab] = useState<"setup" | "overlays" | "logo" | "scene" | "source" | "geometry" | "information" | "appearance" | "export">("setup"), [v070Menu, setV070Menu] = useState(null), [v070ToolQuery, setV070ToolQuery] = useState(""), @@ -3852,7 +3810,7 @@ export default function Home({ uiVersion = "v070" }: { uiVersion?: "legacy" | "v panRef.current = pan; }, [pan]); useEffect(() => { - if (workspaceMode === "simulation") return; + if (workspaceMode === "simulation" || workspaceMode === "plots") return; const stage = canvasStageRef.current; if (!stage) return; const updateBounds = () => { @@ -3892,7 +3850,9 @@ export default function Home({ uiVersion = "v070" }: { uiVersion?: "legacy" | "v }, [mapSequenceActive, mapSequenceWorkspace, resolumeMap]); useEffect(() => { const down = (event: KeyboardEvent) => { - if (event.code === "Space" && !(event.target instanceof HTMLInputElement)) { + const target = event.target instanceof HTMLElement ? event.target : null; + if (event.defaultPrevented || target?.isContentEditable || target?.closest('input, textarea, select, button, [role="textbox"], [role="combobox"], [role="button"]')) return; + if (event.code === "Space") { event.preventDefault(); setSpaceDown(true); } @@ -4534,7 +4494,8 @@ export default function Home({ uiVersion = "v070" }: { uiVersion?: "legacy" | "v () => ({ format: "opticmesh-project", version: importedModels.length || Object.keys(simulationBodyAppearances).length ? 4 : 3, - appVersion: LOCAL_PREVIEW ? "0.8.0-beta" : packageMetadata.version, + appVersion: LOCAL_PREVIEW ? "0.9.0-beta" : packageMetadata.version, + technicalPlots: plotDocument, config, patternStyle, patternCalibration, @@ -4571,7 +4532,7 @@ export default function Home({ uiVersion = "v070" }: { uiVersion?: "legacy" | "v groups: simulationGroups, }, }), - [simulationReflectionPreset, simulationBodyAppearances, importedModels, calculatorSources, config, logoData, logoName, mapView, patternCalibration, patternStyle, rawXml, simulationBackgroundLevel, simulationCamera, simulationCurvature, simulationCurvatureOverrides, simulationDepthM, simulationFloorVisible, simulationGridVisible, simulationSnapEnabled, simulationGroups, simulationLocalNames, simulationLocks, simulationPivot, simulationPivotOverrides, simulationQuality, simulationSource, simulationSourceOverrides, simulationTool, simulationTransformSpace, simulationTransforms, simulationVisibility, sliceOverrides, workspaceMode, xmlName], + [plotDocument, simulationReflectionPreset, simulationBodyAppearances, importedModels, calculatorSources, config, logoData, logoName, mapView, patternCalibration, patternStyle, rawXml, simulationBackgroundLevel, simulationCamera, simulationCurvature, simulationCurvatureOverrides, simulationDepthM, simulationFloorVisible, simulationGridVisible, simulationSnapEnabled, simulationGroups, simulationLocalNames, simulationLocks, simulationPivot, simulationPivotOverrides, simulationQuality, simulationSource, simulationSourceOverrides, simulationTool, simulationTransformSpace, simulationTransforms, simulationVisibility, sliceOverrides, workspaceMode, xmlName], ); const currentProjectSnapshot = useCallback(() => ({ ...projectSnapshot, simulation: { ...projectSnapshot.simulation, camera: simulationCameraMemory.current || projectSnapshot.simulation.camera } }), [projectSnapshot]); const savedProjectSnapshot = useRef | null>(null); @@ -4627,7 +4588,9 @@ export default function Home({ uiVersion = "v070" }: { uiVersion?: "legacy" | "v setPatternStyle({ ...loadedPatternStyle, centerDotSize: normalizeCenterDotSize(loadedPatternStyle.centerDotSize) }); setPatternCalibration(data.patternCalibration === "gamma" || data.patternCalibration === "seam" ? data.patternCalibration : "none"); setCalculatorSources(Array.isArray(data.calculatorSources) && data.calculatorSources.length === 2 ? data.calculatorSources : ["physical", "raster"]); - const restoredWorkspace: WorkspaceMode = data.workspaceMode === "simulation" || data.workspaceMode === "resolume" ? data.workspaceMode : "patterns"; + const restoredWorkspace: WorkspaceMode = data.workspaceMode === "simulation" || data.workspaceMode === "resolume" || data.workspaceMode === "plots" ? data.workspaceMode : "patterns"; + setPlotDocument(readPlotDocument(data.technicalPlots)); + setPlotHistory({ past: [], future: [] }); setWorkspaceMode(restoredWorkspace); setControlTab(restoredWorkspace === "simulation" ? "scene" : "setup"); setV070InspectorTab(restoredWorkspace === "simulation" ? "scene" : restoredWorkspace === "resolume" ? "source" : "setup"); @@ -5545,7 +5508,31 @@ export default function Home({ uiVersion = "v070" }: { uiVersion?: "legacy" | "v ; }; + const plotSource = useMemo(() => ({ + name: /^LO2S.*OpticMesh/.test(config.project) ? "Untitled project" : config.project, width: resolumeMap?.compositionWidth || config.resolutionWidth, + height: resolumeMap?.compositionHeight || config.resolutionHeight, + screens: resolumeMap?.screens.map(s => ({ name: s.name, width: s.width, height: s.height })) || [], + slices: allSlices.map(s => { const pitch = simulationPitchBySlice[s.id] || simulationMasterPitchMm; const scale = renderedSimulationTransforms[s.id]?.scale || [1, 1, 1]; return { ...s, + nominalPitch: sliceOverrides[s.id]?.pixelPitchMm || config.pixelPitchMm, + panelWidth: sliceOverrides[s.id]?.cabinetWidth || config.cabinetWidth, panelHeight: sliceOverrides[s.id]?.cabinetHeight || config.cabinetHeight, + panelPixelsWidth: cabinetPixels({ ...config, ...sliceOverrides[s.id] }).width, panelPixelsHeight: cabinetPixels({ ...config, ...sliceOverrides[s.id] }).height, + effectivePitch: pitch, physicalWidth: s.input.width * pitch / 1000 * Math.abs(scale[0]), + physicalHeight: s.input.height * pitch / 1000 * Math.abs(scale[1]), + }; }), + }), [config, resolumeMap, allSlices, simulationPitchBySlice, simulationMasterPitchMm, renderedSimulationTransforms, sliceOverrides]); + const plotScene = useMemo(() => ({ + projectName: config.project, models: effectiveModels, bodyAppearanceBySlice: simulationBodyBySlice, + slices: allSlices.filter(s => simulationVisibleIds.includes(s.id)), + compositionWidth: resolumeMap?.compositionWidth || config.resolutionWidth, + compositionHeight: resolumeMap?.compositionHeight || config.resolutionHeight, + masterPitchMm: simulationMasterPitchMm, pitchBySlice: simulationPitchBySlice, + depthBySlice: simulationDepthBySlice, curvatureBySlice: simulationCurvatureBySlice, + pivotBySlice: simulationPivotBySlice, transforms: simulationExportTransforms, groups: simulationGroups, + drawPatternTexture: canvas => { canvas.width = 1; canvas.height = 1; const ctx = canvas.getContext('2d')!; ctx.fillStyle = '#506e75'; ctx.fillRect(0, 0, 1, 1); }, + }), [config.project, config.resolutionWidth, config.resolutionHeight, effectiveModels, simulationBodyBySlice, allSlices, simulationVisibleIds, resolumeMap, simulationMasterPitchMm, simulationPitchBySlice, simulationDepthBySlice, simulationCurvatureBySlice, simulationPivotBySlice, simulationExportTransforms, simulationGroups]); + if (uiVersion === "v070") { + const isPlots = workspaceMode === "plots"; const isV0703D = workspaceMode === "simulation", isV070Map = workspaceMode === "resolume", isDome = !isV070Map && !isV0703D && config.projectionFormat === "dome", @@ -5592,17 +5579,18 @@ export default function Home({ uiVersion = "v070" }: { uiVersion?: "legacy" | "v onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); event.currentTarget.parentElement?.querySelector(`button[aria-controls="toolbar-menu-${(v070Menu || item).toLowerCase()}"]`)?.focus(); setV070Menu(null); } }} >{v070Menu === item &&
{ event.stopPropagation(); const action = (event.target as Element).closest("button"); if (action && !action.disabled && !action.hasAttribute("data-menu-keep-open")) { event.currentTarget.parentElement?.querySelector("button[aria-controls]")?.focus(); setV070Menu(null); } }}> {item === "File" && <>{typeof window !== "undefined" && (window as PickerWindow).lo2sDesktop?.recentProjects && }
} - {item === "Export" && (isV0703D ? <>3D scene formats{simulationExporting && Building 3D export…} : <>{isV070Map && resolumeMap && <>})} + {item === "Export" && (isPlots ? <>Technical plots : isV0703D ? <>3D scene formats{simulationExporting && Building 3D export…} : <>{isV070Map && resolumeMap && <>})} {item === "Output" && <>

{patternOutputStatus}} - {item === "Tools" && <>{isV0703D && typeof window !== "undefined" && (window as PickerWindow).lo2sDesktop?.openSpaceMouseSettings && }} + {item === "Tools" && <>{isV0703D && typeof window !== "undefined" && (window as PickerWindow).lo2sDesktop?.openSpaceMouseSettings && }} {item === "Help" && <>} {item === "About" && <>LO2S - OpticMeshVersion {DISPLAY_VERSION} · {BUILD_DESCRIPTION}}
})} -
+
Project{config.project}{startupProjectStatus}
{notice}{notice && }
-
- +
+ +
{isV0703D ? <> : isV070Map ? <> : PROJECTION_FORMATS.map((format) => )}
{isV0703D ? <> : <>}
-
{isV0703D && pauseMainViewport &&
Main viewport pausedFloating Preview stays live. Scene edits continue to update it.
} { if(!additive)setModelSelection([]); setSimulationTransformPreview(null); setSelectedGroupIds([]); setSelectedSliceIds(ids); }} onTransformPreview={setSimulationTransformPreview} onTransformsChange={commitSimulationTransforms} onCameraChange={publishSimulationCamera} onOutputCaptureReady={(capture) => { simulationOutputCaptureRef.current = capture; }} />{!isV0703D &&
{ event.preventDefault(); adjustZoom(zoomRef.current * (event.deltaY > 0 ? 0.9 : 1.1), event.clientX, event.clientY); }}>{emptyPixelMap &&
Import a Resolume XML mapYour input and output maps will appear here.
}= 1 ? "pixelated" : "auto" }} aria-label="LO2S - OpticMesh 0.7 live pattern output" />
}
+
{isV0703D && pauseMainViewport &&
Main viewport pausedFloating Preview stays live. Scene edits continue to update it.
} { if(!additive)setModelSelection([]); setSimulationTransformPreview(null); setSelectedGroupIds([]); setSelectedSliceIds(ids); }} onTransformPreview={setSimulationTransformPreview} onTransformsChange={commitSimulationTransforms} onCameraChange={publishSimulationCamera} onOutputCaptureReady={(capture) => { simulationOutputCaptureRef.current = capture; }} />{!isV0703D && !isPlots &&
{ event.preventDefault(); adjustZoom(zoomRef.current * (event.deltaY > 0 ? 0.9 : 1.1), event.clientX, event.clientY); }}>{emptyPixelMap &&
Import a Resolume XML mapYour input and output maps will appear here.
}= 1 ? "pixelated" : "auto" }} aria-label="LO2S - OpticMesh 0.7 live pattern output" />
}
{isV0703D && } diff --git a/app/plot-layout.ts b/app/plot-layout.ts new file mode 100644 index 0000000..6b5c8fb --- /dev/null +++ b/app/plot-layout.ts @@ -0,0 +1,70 @@ +import type { PlotFrame } from './technical-plots-data'; + +export type Guide = { x1: number; y1: number; x2: number; y2: number; label?: string }; +type Rect = Pick; +const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); +const anchors = (r: Rect, axis: 'x' | 'y') => { const size = axis === 'x' ? r.w : r.h; return [r[axis], r[axis] + size / 2, r[axis] + size]; }; + +export function duplicatePosition(f: Rect, frames: Rect[], grid: number, snap: boolean) { + const gap = snap ? grid : 5; + const next = (n: number) => snap ? Math.ceil(n / grid) * grid : n; + const previous = (n: number) => snap ? Math.floor(n / grid) * grid : n; + const candidates = [ + { x: next(f.x + f.w + gap), y: f.y }, + { x: f.x, y: next(f.y + f.h + gap) }, + { x: previous(f.x - f.w - gap), y: f.y }, + { x: f.x, y: previous(f.y - f.h - gap) }, + ]; + return candidates.find(p => p.x >= 8 && p.y >= 27 && p.x + f.w <= 412 && p.y + f.h <= 257 && !frames.some(r => p.x < r.x + r.w && p.x + f.w > r.x && p.y < r.y + r.h && p.y + f.h > r.y)); +} + +// Work in paper millimetres; the caller supplies a screen-pixel sized tolerance. +export function snapLayout(raw: Rect, others: Rect[], grid: number, snap: boolean, tolerance = 2, resize = false) { + const f = { ...raw }, guides: Guide[] = []; + const peers = others.filter(r => r.id !== f.id); + for (const axis of ['x', 'y'] as const) { + const size = axis === 'x' ? 'w' : 'h', limit = axis === 'x' ? 412 : 257, minimum = axis === 'x' ? 8 : 27; + const moving = resize ? [f[axis] + f[size]] : anchors(f, axis); + let best = tolerance + .0001, delta: number | undefined; + if (snap) { + for (const r of peers) for (const target of anchors(r, axis)) for (const point of moving) { + const d = target - point; if (Math.abs(d) < best) { best = Math.abs(d); delta = d; } + } + if (!resize) { + // Equal spacing both between two neighbours and extending an existing row. + const cross = axis === 'x' ? 'y' : 'x', crossSize = axis === 'x' ? 'h' : 'w'; + const aligned = peers.filter(r => r[cross] < f[cross] + f[crossSize] && r[cross] + r[crossSize] > f[cross]); + for (const a of aligned) for (const b of aligned) { + const gap = b[axis] - (a[axis] + a[size]); if (a.id === b.id || gap < 0) continue; + for (const target of [b[axis] + b[size] + gap, a[axis] - gap - f[size], (a[axis] + a[size] + b[axis] - f[size]) / 2]) { + const d = target - f[axis]; if (Math.abs(d) < best) { best = Math.abs(d); delta = d; } + } + } + } + } + const quantize = (n: number) => Math.round(n / (snap ? grid : .1)) * (snap ? grid : .1); + if (resize) f[size] = clamp(delta === undefined ? quantize(f[size]) : f[size] + delta, 25, limit - f[axis]); + else f[axis] = clamp(delta === undefined ? quantize(f[axis]) : f[axis] + delta, minimum, limit - f[size]); + } + if (snap) for (const axis of ['x', 'y'] as const) { + for (const r of peers) for (const a of anchors(f, axis)) if (anchors(r, axis).some(b => Math.abs(a - b) < .05)) { + guides.push(axis === 'x' ? { x1: a, x2: a, y1: Math.min(f.y, r.y) - 2, y2: Math.max(f.y + f.h, r.y + r.h) + 2 } : { y1: a, y2: a, x1: Math.min(f.x, r.x) - 2, x2: Math.max(f.x + f.w, r.x + r.w) + 2 }); + } + } + // Nearest non-overlapping neighbour in each direction, with real paper distances. + for (const axis of ['x', 'y'] as const) for (const sign of [-1, 1]) { + const size = axis === 'x' ? 'w' : 'h', cross = axis === 'x' ? 'y' : 'x', crossSize = axis === 'x' ? 'h' : 'w'; + const candidates = peers.filter(r => r[cross] < f[cross] + f[crossSize] && r[cross] + r[crossSize] > f[cross]).map(r => ({ r, gap: sign > 0 ? r[axis] - f[axis] - f[size] : f[axis] - r[axis] - r[size] })).filter(c => c.gap >= 0).sort((a, b) => a.gap - b.gap); + const nearest = candidates[0]; if (!nearest) continue; + const start = sign > 0 ? f[axis] + f[size] : nearest.r[axis] + nearest.r[size]; + const middle = (Math.max(f[cross], nearest.r[cross]) + Math.min(f[cross] + f[crossSize], nearest.r[cross] + nearest.r[crossSize])) / 2; + const label = `${Number(nearest.gap.toFixed(1))} mm`; + guides.push(axis === 'x' ? { x1: start, x2: start + nearest.gap, y1: middle, y2: middle, label } : { y1: start, y2: start + nearest.gap, x1: middle, x2: middle, label }); + } + return { frame: f, guides }; +} + +export function framingTransform(captured: Pick, next: Pick) { + const scale = next.viewZoom / captured.viewZoom; + return { scale, x: captured.panX * scale - next.panX, y: next.panY - captured.panY * scale }; +} diff --git a/app/plot-map-labels.ts b/app/plot-map-labels.ts new file mode 100644 index 0000000..79f75d9 --- /dev/null +++ b/app/plot-map-labels.ts @@ -0,0 +1,64 @@ +import type { PlotFrame, PlotSlice, PlotSource } from './technical-plots-data'; + +export function wrapPlotText(text: string, width: number, size = 3): string[] { + const limit = Math.max(1, Math.floor(width / (size * .56))); + return text.split('\n').flatMap(p => { + const result: string[] = []; let row = ''; + for (const word of p.split(/\s+/)) { + if (row.length + word.length + 1 > limit && row) { result.push(row); row = ''; } + for (let i = 0; i < word.length; i += limit) { const part = word.slice(i, i + limit); if (i) { result.push(row); row = ''; } row += (row ? ' ' : '') + part; } + } + result.push(row); return result; + }); +} +const value = (n: number) => Number.isFinite(n) ? String(Number(n.toFixed(3))) : '—'; +export function mapSliceLabel(slice: PlotSlice, output: boolean) { + const r = output ? slice.output : slice.input; + return `${slice.name}\n${output ? 'Output' : 'Input'} X: ${value(r.x)} · Y: ${value(r.y)} px\n${value(r.width)} × ${value(r.height)} px\n${value(slice.physicalWidth)} × ${value(slice.physicalHeight)} m`; +} + +export function mapLabelLayout(f: PlotFrame, source: PlotSource) { + const output = f.kind === 'output', screen = source.screens[f.screen]; + const width = Math.max(1, (output ? screen?.width : source.width) || 1), height = Math.max(1, (output ? screen?.height : source.height) || 1); + const rows = source.slices.filter(s => !output || s.screenName === screen?.name); + const size = 2.8 * f.fontSize / 9, columns = Math.max(1, Math.floor((f.w - 8) / (70 * f.fontSize / 9))), cardWidth = (f.w - 8) / columns; + const external = new Set(); + let cardHeight = 0, legendHeight = 0, scale = 1; + for (let pass = 0; pass <= rows.length; pass++) { + const legend = rows.filter(s => external.has(s.id)); + cardHeight = Math.max(0, ...legend.map(s => wrapPlotText(`${String(source.slices.indexOf(s) + 1).padStart(2, '0')} · ${mapSliceLabel(s, output)}`, cardWidth - 5, size).length * size * 1.4 + 4)); + legendHeight = legend.length ? Math.ceil(legend.length / columns) * cardHeight + 6 : 0; + scale = Math.max(.0001, Math.min((f.w - 26) / width, Math.max(5, f.h - 38 - legendHeight) / height)); + const before = external.size; + for (const s of rows) { + const r = output ? s.output : s.input, rw = r.width * scale, rh = r.height * scale; + // Reserve callouts for labels that cannot fit after wrapping, not an arbitrary slice width. + const count = wrapPlotText(mapSliceLabel(s, output), Math.max(1, rw - 4), size).length; + const overlaps = rows.some(other => { if (other === s) return false; const q = output ? other.output : other.input; return r.x < q.x + q.width - .001 && r.x + r.width > q.x + .001 && r.y < q.y + q.height - .001 && r.y + r.height > q.y + .001; }); + if (rw - 4 < size * .56 * 6 || rh < count * size * 1.4 + 10 || overlaps || r.points?.some(p => !([r.x, r.x + r.width].includes(p.x) && [r.y, r.y + r.height].includes(p.y)))) external.add(s.id); + } + if (external.size === before) break; + } + return { rows, external, size, columns, cardWidth, cardHeight, legendHeight, scale, width, height, overflow: legendHeight + 48 > f.h }; +} + + +export function screenDetailLayout(f: PlotFrame, s: PlotSlice) { + const fs = f.fontSize / 9, dimensionSize = 3 * fs, noteSize = 2.6 * fs; + const note = `Panel grid · ${value(s.panelWidth)} × ${value(s.panelHeight)} mm · ${s.panelPixelsWidth} × ${s.panelPixelsHeight} px per panel · Partial edge panels retained`; + const noteLines = wrapPlotText(note, f.w - 8, noteSize).length; + const noteBaseline = f.h - 4 - noteSize * .3 - (noteLines - 1) * noteSize * 1.4; + const noteTop = noteBaseline - noteSize; + const top = 44 * Math.max(1, fs); + // Include the dimension text itself in the drawing envelope on both axes. + const left = 4 + dimensionSize * 1.3 + 3, right = f.w - 4; + const bottom = noteTop - 4 - dimensionSize * 1.3 - 3; + const width = right - left, height = bottom - top; + const horizontalTextWidth = `${s.input.width} px`.length * dimensionSize * .6; + const verticalTextLength = `${s.input.height} px`.length * dimensionSize * .6; + const fits = width >= Math.max(10, horizontalTextWidth) && height >= Math.max(10, verticalTextLength); + const scale = Math.max(.0001, Math.min(width / Math.max(1, s.input.width), height / Math.max(1, s.input.height))); + const w = s.input.width * scale, h = s.input.height * scale; + const x = left + (width - w) / 2, y = top + (height - h) / 2; + return { x, y, w, h, scale, dimensionSize, widthY: y + h + 3 + dimensionSize, heightX: x - 3, note, noteSize, noteBaseline, noteLines, noteTop, fits }; +} diff --git a/app/plot-number-field.tsx b/app/plot-number-field.tsx new file mode 100644 index 0000000..80051ba --- /dev/null +++ b/app/plot-number-field.tsx @@ -0,0 +1,18 @@ +import { useState } from 'react'; +import NumericInput from './numeric-input'; +import FieldStepper from './field-stepper'; +import UiIcon from './ui-icon'; +import { evaluateExpression } from './expression'; + +export default function PlotNumberField({ label, value, onChange, min = -10000, max = 10000, suffix = '' }: { label: string; value: number; onChange: (n: number) => void; min?: number; max?: number; suffix?: string }) { + const [draft, setDraft] = useState(String(value)), [invalid, setInvalid] = useState(false); + const [previous, setPrevious] = useState(value); + if (previous !== value) { setPrevious(value); setDraft(String(value)); setInvalid(false); } + const apply = (n: number) => { const next = Math.round(Math.max(min, Math.min(max, n)) * 10000) / 10000; setDraft(String(next)); setInvalid(false); if (next !== value) onChange(next); }; + const commit = () => { const n = evaluateExpression(draft, true); if (n === null) { setInvalid(true); return; } apply(n); }; + const adjust = (direction: number, shift: boolean) => apply((evaluateExpression(draft, true) ?? value) + direction * (shift ? 10 : 1)); + return ; +} diff --git a/app/plot-print-dialog.tsx b/app/plot-print-dialog.tsx new file mode 100644 index 0000000..1f3f13b --- /dev/null +++ b/app/plot-print-dialog.tsx @@ -0,0 +1,18 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import PlotNumberField from './plot-number-field'; + +type Printer = { name: string; displayName?: string; isDefault?: boolean }; +export default function PlotPrintDialog({ pages, busy, onClose, onPrint, onPdf, onNotice }: { pages: ReactNode[]; busy: boolean; onClose: () => void; onPrint: (device: string, copies: number, page?: number) => void; onPdf: () => void; onNotice: (message: string) => void }) { + const root = useRef(null), [page, setPage] = useState(0), [printers, setPrinters] = useState([]), [device, setDevice] = useState(''), [copies, setCopies] = useState(1), [range, setRange] = useState('all'), [error, setError] = useState(''); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; const dialog = root.current!; dialog.showModal(); dialog.focus(); let disposed = false; + const bridge = (window as unknown as { lo2sDesktop?: { getPlotPrinters?: () => Promise<{ printers?: Printer[]; error?: string }> } }).lo2sDesktop; + const request = bridge?.getPlotPrinters ? bridge.getPlotPrinters() : Promise.resolve({ error: 'Restart the local desktop app to enable printer selection. PDF export is available.', printers: [] }); + request.then(result => { if (disposed) return; setPrinters(result.printers || []); setDevice(result.printers?.find(p => p.isDefault)?.name || result.printers?.[0]?.name || ''); setError(result.error || (!result.printers?.length ? 'No printers found. You can export the sheets to PDF.' : '')); }).catch(() => { if (!disposed) setError('Unable to list printers. You can export the sheets to PDF.'); }); + return () => { disposed = true; dialog.close(); previous?.focus(); }; + }, []); + return { e.preventDefault(); if (!busy) onClose(); }} onKeyDown={e => { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (!busy) onClose(); } + if (e.key === 'Tab') { const controls = Array.from(root.current!.querySelectorAll('button:not(:disabled),select:not(:disabled),input:not(:disabled)')); const first = controls[0], last = controls.at(-1); if (e.shiftKey && (document.activeElement === first || document.activeElement === root.current)) { e.preventDefault(); last?.focus(); } else if (!e.shiftKey && (document.activeElement === last || document.activeElement === root.current)) { e.preventDefault(); first?.focus(); } } + }}>

Print technical plots

{pages[page]}
; +} diff --git a/app/plot-view-framing.tsx b/app/plot-view-framing.tsx new file mode 100644 index 0000000..cc7f8fd --- /dev/null +++ b/app/plot-view-framing.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from 'react'; +import type { PlotFrame } from './technical-plots-data'; +import { framingTransform } from './plot-layout'; + +type Camera = Pick; +export default function PlotViewFraming({ frame, captured, image, onChange, onInteraction }: { frame: PlotFrame; captured?: PlotFrame; image?: string; onChange: (camera: Camera) => void; onInteraction: (active: boolean) => void }) { + const surface = useRef(null), timer = useRef | undefined>(undefined); + const [draft, setDraft] = useState(null); + const latest = useRef({ frame, onChange, onInteraction }); + useEffect(() => { latest.current = { frame, onChange, onInteraction }; }); + const camera = useRef(frame), interacting = useRef(false), previousView = useRef(frame.view); + useEffect(() => { + if (previousView.current !== frame.view) { + previousView.current = frame.view; clearTimeout(timer.current); + interacting.current = false; latest.current.onInteraction(false); + } + if (!interacting.current) { camera.current = frame; setDraft(null); } + }, [frame]); + const finish = () => { + clearTimeout(timer.current); + if (!interacting.current) return; + interacting.current = false; latest.current.onChange({ ...camera.current, panX: Number(camera.current.panX.toFixed(4)), panY: Number(camera.current.panY.toFixed(4)) }); latest.current.onInteraction(false); + }; + useEffect(() => { + const target = surface.current!; + const wheel = (e: WheelEvent) => { + e.preventDefault(); e.stopPropagation(); + interacting.current = true; latest.current.onInteraction(true); + const old = camera.current, zoom = Math.max(.1, Math.min(20, old.viewZoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1))), ratio = zoom / old.viewZoom; + camera.current = { viewZoom: zoom, panX: Math.max(-500, Math.min(500, old.panX * ratio)), panY: Math.max(-500, Math.min(500, old.panY * ratio)) }; + setDraft(camera.current); clearTimeout(timer.current); timer.current = setTimeout(finish, 250); + }; + target.addEventListener('wheel', wheel, { passive: false }); + return () => { target.removeEventListener('wheel', wheel); finish(); }; + }, []); + const transform = framingTransform(captured || frame, draft || frame); + return
{ + if (event.button !== 0) return; + event.preventDefault(); clearTimeout(timer.current); + const target = event.currentTarget, rect = target.getBoundingClientRect(), start = { ...camera.current }, x = event.clientX, y = event.clientY; + target.setPointerCapture(event.pointerId); interacting.current = true; latest.current.onInteraction(true); + const move = (e: PointerEvent) => { + camera.current = { ...start, panX: Math.max(-500, Math.min(500, start.panX - (e.clientX - x) / rect.width * 100)), panY: Math.max(-500, Math.min(500, start.panY + (e.clientY - y) / rect.height * 100)) }; + setDraft(camera.current); + }; + const stop = () => { target.removeEventListener('pointermove', move); target.removeEventListener('pointerup', stop); target.removeEventListener('pointercancel', stop); finish(); }; + target.addEventListener('pointermove', move); target.addEventListener('pointerup', stop); target.addEventListener('pointercancel', stop); + }}> + {/* This is an in-memory capture, not a network image. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {image && } +
; +} diff --git a/app/scene-export.ts b/app/scene-export.ts index b1ea479..2e881ed 100644 --- a/app/scene-export.ts +++ b/app/scene-export.ts @@ -80,7 +80,7 @@ function deterministicUuid(value: string) { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } -function buildScene(options: SceneExportOptions): BuiltScene { +export function buildScene(options: SceneExportOptions): BuiltScene { const scene = new THREE.Scene(); scene.name = options.projectName; const root = new THREE.Group(); @@ -189,7 +189,7 @@ function buildScene(options: SceneExportOptions): BuiltScene { return { scene, texture, textureCanvas, meshesBySlice, triangleCount }; } -function disposeBuiltScene(built: BuiltScene) { +export function disposeBuiltScene(built: BuiltScene) { built.scene.traverse((object) => { if (!(object instanceof THREE.Mesh)) return; object.geometry.dispose(); diff --git a/app/technical-plots-data.ts b/app/technical-plots-data.ts new file mode 100644 index 0000000..8981b97 --- /dev/null +++ b/app/technical-plots-data.ts @@ -0,0 +1,71 @@ +export const PAPER = { width: 420, height: 297 } as const; +export const VIEW_DIRECTIONS = { + front: [0, 0, 1], back: [0, 0, -1], left: [-1, 0, 0], right: [1, 0, 0], top: [0, 1, 0], bottom: [0, -1, 0], + 'Upper front right': [1, 1, 1], 'Upper front left': [-1, 1, 1], 'Upper rear right': [1, 1, -1], 'Upper rear left': [-1, 1, -1], + 'Lower front right': [1, -1, 1], 'Lower front left': [-1, -1, 1], 'Lower rear right': [1, -1, -1], 'Lower rear left': [-1, -1, -1], +} satisfies Record; +export const viewLabel = (view: string) => view.replace(/\b[a-z]/g, letter => letter.toUpperCase()); +export type PlotKind = 'input' | 'output' | 'view' | 'schedule' | 'text' | 'pattern'; +export type PlotFrame = { id: string; kind: PlotKind; title: string; x: number; y: number; w: number; h: number; screen: number; view: keyof typeof VIEW_DIRECTIONS; style: 'shaded' | 'wireframe'; stage: boolean; text: string; textBold?: boolean; textItalic?: boolean; textUnderline?: boolean; textAlign?: 'left' | 'center' | 'right'; offset: number; autoTitle: boolean; fontSize: number; viewZoom: number; panX: number; panY: number }; +export type PlotSheet = { id: string; title: string; frames: PlotFrame[] }; +export type PlotDocument = { format: 'opticmesh-plot-template'; version: 1; projectTitle: string; company: string; logo: string; author: string; revision: string; date: string; notes: string; fontFamily: string; fontSize: number; grid: number; snap: boolean; sheets: PlotSheet[] }; +export type PlotRect = { x: number; y: number; width: number; height: number; points?: { x: number; y: number }[] }; +export type PlotSlice = { id: string; name: string; screenName: string; input: PlotRect; output: PlotRect; nominalPitch: number; effectivePitch: number; physicalWidth: number; physicalHeight: number; warped: boolean; panelWidth: number; panelHeight: number; panelPixelsWidth: number; panelPixelsHeight: number }; +export type PlotSource = { name: string; width: number; height: number; screens: { name: string; width: number; height: number }[]; slices: PlotSlice[] }; +export const uid = () => crypto.randomUUID(); +export function frame(kind: PlotKind, x = 12, y = 30, w = 396, h = 222): PlotFrame { + return { id: uid(), kind, title: { input: 'Input map', output: 'Output map', view: 'Upper Front Right', schedule: 'Screen specifications', text: 'Delivery notes', pattern: 'Screen detail' }[kind], x, y, w, h, screen: 0, view: 'Upper front right', style: 'shaded', stage: true, text: '', offset: 0, autoTitle: true, fontSize: 9, viewZoom: 1, panX: 0, panY: 0 }; +} +export function sheet(preset: 'input' | 'output' | 'views' | 'schedule' | 'notes' | 'blank' | 'pattern'): PlotSheet { + const frames = preset === 'blank' ? [] : preset === 'views' ? [frame('view', 12, 30, 194, 108), frame('view', 214, 30, 194, 108), frame('view', 12, 146, 194, 108), frame('view', 214, 146, 194, 108)] : [frame(preset === 'notes' ? 'text' : preset)]; + if (preset === 'views') frames.forEach((f, i) => { f.view = ['front', 'top', 'Upper front left', 'Upper front right'][i] as PlotFrame['view']; f.title = viewLabel(f.view); }); + return { id: uid(), title: { input: 'Input mapping', output: 'Output mapping', views: 'Stage views', schedule: 'Screen specifications', notes: 'Content delivery', pattern: 'Screen detail', blank: 'Untitled sheet' }[preset], frames }; +} +export function defaultPlotDocument(): PlotDocument { + return { format: 'opticmesh-plot-template', version: 1, projectTitle: '', company: '', logo: '', author: '', revision: 'A', date: new Date().toISOString().slice(0, 10), notes: '', fontFamily: 'Arial', fontSize: 9, grid: 5, snap: true, sheets: [sheet('input'), sheet('output'), sheet('views'), sheet('schedule')] }; +} +export function readPlotDocument(value: unknown): PlotDocument { + if (!value) return defaultPlotDocument(); + const v = value as PlotDocument; + if (v.format !== 'opticmesh-plot-template' || v.version !== 1 || !Array.isArray(v.sheets) || !v.sheets.length || v.sheets.length > 100) throw new Error('Choose a supported plot template (up to 100 sheets).'); + const str = (s: unknown, max = 4000) => typeof s === 'string' ? s.slice(0, max) : ''; + const number = (n: unknown, min: number, max: number, fallback: number) => typeof n === 'number' && Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : fallback; + return { format: v.format, version: 1, projectTitle: str(v.projectTitle, 100), company: str(v.company, 100), logo: typeof v.logo === 'string' && v.logo.length < 3000000 && /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/=]+$/.test(v.logo) ? v.logo : '', author: str(v.author, 100), revision: str(v.revision, 30), date: str(v.date, 30), notes: str(v.notes, 500), fontFamily: ['Arial', 'Verdana', 'Georgia', 'Courier New'].includes(v.fontFamily) ? v.fontFamily : 'Arial', fontSize: number(v.fontSize, 6, 16, 9), grid: number(v.grid, 1, 50, 5), snap: v.snap !== false, sheets: v.sheets.map(s => ({ id: uid(), title: str(s.title, 100), frames: (Array.isArray(s.frames) ? s.frames.slice(0, 24) : []).map(f => { + const x = number(f.x, 8, 387, 12), y = number(f.y, 27, 232, 30); + return { ...frame(['input', 'output', 'view', 'schedule', 'text', 'pattern'].includes(f.kind) ? f.kind : 'text'), title: f.kind === 'view' && (f.autoTitle !== false && (f.autoTitle === true || f.title === f.view || f.title === 'Scene view')) ? viewLabel(Object.hasOwn(VIEW_DIRECTIONS, f.view) ? f.view : 'front') : str(f.title, 100), x, y, w: number(f.w, 25, 412 - x, 100), h: number(f.h, 25, 257 - y, 80), screen: Math.floor(number(f.screen, 0, 10000, 0)), view: Object.hasOwn(VIEW_DIRECTIONS, f.view) ? f.view : 'front', style: f.style === 'wireframe' ? 'wireframe' : 'shaded', stage: f.stage !== false, text: str(f.text), textBold: f.textBold === true, textItalic: f.textItalic === true, textUnderline: f.textUnderline === true, textAlign: f.textAlign === 'center' || f.textAlign === 'right' ? f.textAlign : 'left', offset: Math.floor(number(f.offset, 0, 100000, 0)), autoTitle: f.autoTitle ?? (f.title === f.view || f.title === 'Scene view'), fontSize: number(f.fontSize, 6, 24, 9), viewZoom: number(f.viewZoom, .1, 20, 1), panX: number(f.panX, -500, 500, 0), panY: number(f.panY, -500, 500, 0) }; + }) })) }; +} +export const scheduleCapacity = (f: PlotFrame) => Math.max(1, Math.floor((f.h - 20) / specificationRowHeight(f))); +export function plotWarnings(document: PlotDocument, source: PlotSource) { + const result: string[] = []; + for (const s of document.sheets) for (const f of s.frames) { + if (f.x < 8 || f.y < 27 || f.x + f.w > 412.01 || f.y + f.h > 257.01) result.push(`${s.title}: a frame extends into the margin or title block.`); + if (f.w < 40 || f.h < (f.kind === 'pattern' ? 80 : 50)) result.push(`${s.title}: increase the frame size to fit its content.`); + if (f.kind === 'pattern' && !source.slices[f.screen]) result.push(`${s.title}: choose an available screen slice.`); + if (f.kind === 'schedule' && f.h < specificationRowHeight(f) + 20) result.push(`${s.title}: increase the specifications frame height for this text size.`); + if (f.kind === 'schedule' && f.w < 160) result.push(`${s.title}: specifications need a frame at least 160 mm wide.`); + if ((f.kind === 'input' || f.kind === 'output' || f.kind === 'schedule' || f.kind === 'pattern') && !source.slices.length) result.push(`${s.title}: no pixel map loaded.`); + if (f.kind === 'output' && !source.screens[f.screen]) result.push(`${s.title}: choose an available output screen.`); + } + return [...new Set(result)]; +} +// Continuations retain the exact sheet layout and paper size; nothing is silently omitted. +export function printSheets(document: PlotDocument, source: PlotSource): PlotSheet[] { + return document.sheets.flatMap(s => { + const count = Math.max(1, ...s.frames.filter(f => f.kind === 'schedule').map(f => Math.ceil(Math.max(0, source.slices.length - f.offset) / scheduleCapacity(f)))); + return Array.from({ length: count }, (_, page) => ({ ...s, id: `${s.id}-${page}`, title: s.title + (page ? ` / continued ${page + 1}` : ''), frames: s.frames.map(f => f.kind === 'schedule' ? { ...f, offset: f.offset + page * scheduleCapacity(f) } : f) })); + }); +} +export function ratio(w: number, h: number) { return h > 0 ? `${Number((w / h).toFixed(3))}:1` : '—'; } +export const value = (n: number, places = 3) => Number.isFinite(n) ? String(Number(n.toFixed(places))) : '—'; + +export const specificationRowHeight = (f: PlotFrame) => f.w < 300 ? Math.max(42, f.fontSize / 9 * 42) : Math.max(17, f.fontSize / 9 * 17); +export function placeFrame(f: PlotFrame, x: number, y: number, grid: number, snap: boolean) { + const quantize = (n: number) => snap ? Math.round(n / grid) * grid : Math.round(n * 10) / 10; + return { x: Math.max(8, Math.min(412 - f.w, quantize(x))), y: Math.max(27, Math.min(257 - f.h, quantize(y))) }; +} +export const viewSignature = (f: PlotFrame) => JSON.stringify([f.w, f.h, f.view, f.style, f.stage, f.viewZoom, f.panX, f.panY, f.fontSize]); +export function reorderSheets(sheets: PlotSheet[], id: string, before: number) { + const from = sheets.findIndex(s => s.id === id); if (from < 0) return sheets; + const list = sheets.filter(s => s.id !== id); list.splice(Math.max(0, Math.min(list.length, before > from ? before - 1 : before)), 0, sheets[from]); return list; +} diff --git a/app/technical-plots-paper.tsx b/app/technical-plots-paper.tsx new file mode 100644 index 0000000..4371fd0 --- /dev/null +++ b/app/technical-plots-paper.tsx @@ -0,0 +1,103 @@ +import { type PlotDocument, type PlotFrame, type PlotSheet, type PlotSource, ratio, value, scheduleCapacity, specificationRowHeight, viewLabel } from './technical-plots-data'; + +import { framingTransform } from './plot-layout'; +import { wrapPlotText as lines, mapSliceLabel, mapLabelLayout, screenDetailLayout } from './plot-map-labels'; + +function Text({ text, x, y, width, size = 3, max = 5, align = 'left' }: { text: string; x: number; y: number; width: number; size?: number; max?: number; align?: PlotFrame['textAlign'] }) { + const all = lines(text, width, size); + const anchorX = x + (align === 'center' ? width / 2 : align === 'right' ? width : 0); + return {all.slice(0, max).map((line, i) => {line}{i === max - 1 && all.length > max ? '…' : ''})}; +} +function MapFrame({ f, source }: { f: PlotFrame; source: PlotSource }) { + const output = f.kind === 'output', screen = source.screens[f.screen]; + const layout = mapLabelLayout(f, source), { rows, external, size, columns, cardWidth, cardHeight, legendHeight, scale, width, height } = layout; + const x = (f.w - width * scale) / 2, y = 19 + Math.max(0, (f.h - 38 - legendHeight - height * scale) / 2); + const legend = rows.filter(s => external.has(s.id)); + return <> + {output ? screen?.name : 'Composition'} · {width} × {height} px · {ratio(width, height)} · Origin: top left + + {rows.map(s => { const r = output ? s.output : s.input, rx = x + r.x * scale, ry = y + r.y * scale, rw = r.width * scale, rh = r.height * scale; const id = source.slices.indexOf(s) + 1; + return + {r.points && r.points.length > 2 ? `${x + p.x * scale},${y + p.y * scale}`).join(' ')} fill="#d4dcd6" fillOpacity={.65} stroke="#34484c" strokeWidth={.35}/> : } + {String(id).padStart(2, '0')} + {!external.has(s.id) && } + ; + })} + {width} px + {height} px + {legend.map((s, i) => { + const rx = 4 + i % columns * cardWidth, ry = f.h - legendHeight + Math.floor(i / columns) * cardHeight; + return ; + })} + {output ? 'Output' : 'Input'} coordinates: top-left origin · IDs match screen specifications · Physical size in metres + ; +} +function PatternDetail({ f, source }: { f: PlotFrame; source: PlotSource }) { + const fs = f.fontSize / 9; + const s = source.slices[f.screen]; if (!s) return Choose a screen slice.; + const layout = screenDetailLayout(f, s), { x, y, w, h, scale } = layout; + if (!layout.fits) return ; + return <> + {String(source.slices.indexOf(s) + 1).padStart(2, '0')} · {s.name} + Raster: {s.input.width} × {s.input.height} px · {ratio(s.input.width, s.input.height)} + Pixel pitch: {value(s.nominalPitch)} mm nominal / {value(s.effectivePitch)} mm effective + Surface size: {value(s.physicalWidth)} × {value(s.physicalHeight)} m · {ratio(s.physicalWidth, s.physicalHeight)} + + + + + {s.input.width} px{s.input.height} px + + ; +} +function Schedule({ f, source }: { f: PlotFrame; source: PlotSource }) { + if (f.w < 300) return <>{source.slices.slice(f.offset, f.offset + scheduleCapacity(f)).map((s, i) => { + const size = 2.8 * f.fontSize / 9, y = 15 + i * specificationRowHeight(f); + const cells = [`${String(f.offset + i + 1).padStart(2, '0')} · ${s.name}`, `Output: ${s.screenName}`, `Raster: ${s.input.width} × ${s.input.height} px · ${ratio(s.input.width, s.input.height)}`, `Pitch: ${value(s.nominalPitch)} / ${value(s.effectivePitch)} mm`, `Size: ${value(s.physicalWidth)} × ${value(s.physicalHeight)} m`, `Panel: ${value(s.panelWidth)} × ${value(s.panelHeight)} mm · ${s.panelPixelsWidth} × ${s.panelPixelsHeight} px`, `Input: ${value(s.input.x)}, ${value(s.input.y)} px`, `Output: ${value(s.output.x)}, ${value(s.output.y)} px · ${value(s.output.width)} × ${value(s.output.height)} px`]; + return {cells.map((t, j) => )}; + })}; + const columns = [4, 17, 79, 124, 170, 219, 274, 329].map(n => n * f.w / 396); + const labels = ['ID', 'SCREEN / OUTPUT', 'RASTER / RATIO', 'PITCH (mm)', 'SIZE / RATIO', 'PANEL / PIXELS', 'INPUT ORIGIN (px)', 'OUTPUT (px)']; + return <> + + {labels.map((l, i) => {l})} + {source.slices.slice(f.offset, f.offset + scheduleCapacity(f)).map((s, i) => { + const y = 22 + i * specificationRowHeight(f); + const cells = [String(f.offset + i + 1).padStart(2, '0'), `${s.name}\n${s.screenName}`, `${s.input.width} × ${s.input.height}\n${ratio(s.input.width, s.input.height)}`, `${value(s.nominalPitch)} nominal\n${value(s.effectivePitch)} effective`, `${value(s.physicalWidth)} × ${value(s.physicalHeight)} m\n${ratio(s.physicalWidth, s.physicalHeight)}`, `${value(s.panelWidth)} × ${value(s.panelHeight)} mm\n${s.panelPixelsWidth} × ${s.panelPixelsHeight} px`, `${value(s.input.x)} , ${value(s.input.y)}\n${s.warped ? 'Warped surface' : 'Rectangular surface'}`, `${value(s.output.x)} , ${value(s.output.y)}\n${value(s.output.width)} × ${value(s.output.height)}`]; + return {cells.map((t, j) => )}; + })} + ; +} +export function PlotPaper({ document, sheet, source, page, total, images = {}, previewCameras = {} }: { document: PlotDocument; sheet: PlotSheet; source: PlotSource; page: number; total: number; images?: Record; previewCameras?: Record }) { + return + + + {sheet.title} + TECHNICAL PLOT / {String(page).padStart(2, '0')} + {sheet.frames.map(f => + + + + {f.title} + {(f.kind === 'input' || f.kind === 'output') && } + {f.kind === 'pattern' && } + {f.kind === 'schedule' && } + {f.kind === 'text' && } + {f.kind === 'view' && <>{images[f.id] ? {(() => { + const captured = previewCameras[f.id], t = captured && captured.view === f.view ? framingTransform(captured, f) : { x: 0, y: 0, scale: 1 }; + const w = f.w - 4, h = f.h - 19; + return ; + })()} : {source.slices.length ? 'Refresh scene views' : 'Load a scene, then refresh views'}}{viewLabel(f.view)} · {f.style} · Parallel projection · Not to scale} + + )} + + {document.logo && } + {!document.logo && } + + PROJECT + + DRAWN BY / ISSUE DATE + REVISION{document.revision} + A3 · 420 × 297 mm{page} / {total} + ; +} diff --git a/app/technical-plots-scene.ts b/app/technical-plots-scene.ts new file mode 100644 index 0000000..e077e7d --- /dev/null +++ b/app/technical-plots-scene.ts @@ -0,0 +1,106 @@ +import * as THREE from 'three'; +import { buildScene, disposeBuiltScene, type SceneExportOptions } from './scene-export'; +import { VIEW_DIRECTIONS, type PlotFrame } from './technical-plots-data'; +import { ModelLayer } from './model-data'; + +// Retained only while Technical Plots is active. Camera changes reuse GPU resources. +export function createPlotCaptureSession(options: SceneExportOptions) { + let renderer: THREE.WebGLRenderer | undefined; + let built: ReturnType | undefined; + const layer = new ModelLayer(); + const originals = new Map(); + const visibility = new Map(); + const meshes: THREE.Mesh[] = []; + const styles: THREE.Material[] = []; + let running = false, disposed = false, modelsLoaded = false; + const boundsCache = new Map(); + function cleanup() { + for (const [mesh, material] of originals) mesh.material = material; + built?.scene.remove(layer.root); layer.dispose(); + if (built) disposeBuiltScene(built); + styles.forEach(m => m.dispose()); renderer?.dispose(); renderer?.forceContextLoss(); + built = undefined; renderer = undefined; originals.clear(); meshes.length = 0; visibility.clear(); boundsCache.clear(); + } + return { + dispose() { disposed = true; if (!running) cleanup(); }, + async capture(frames: PlotFrame[], signal: AbortSignal, ids: Record = {}, fontFamily = 'Arial') { + if (disposed || running) throw new Error('Plot capture is unavailable.'); + const images: Record = {}; + if (!frames.length) return images; + running = true; + try { + await new Promise(resolve => setTimeout(resolve, 16)); signal.throwIfAborted(); + if (!built) { + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true }); + renderer.setPixelRatio(1); renderer.setClearColor(0xffffff); renderer.outputColorSpace = THREE.SRGBColorSpace; + built = buildScene({ ...options, models: [] }); + built.scene.add(layer.root); built.scene.background = new THREE.Color(0xffffff); + styles.push(new THREE.MeshStandardMaterial({ color: 0xb7c1bd, roughness: 1, side: THREE.DoubleSide }), new THREE.MeshBasicMaterial({ color: 0x56686d, wireframe: true }), new THREE.MeshBasicMaterial({ color: 0x506e75, side: THREE.DoubleSide })); + built.scene.add(new THREE.HemisphereLight(0xffffff, 0x778080, 2)); + const light = new THREE.DirectionalLight(0xffffff, 2); light.position.set(20, 40, 30); built.scene.add(light); + } + if (!modelsLoaded && frames.some(f => f.stage)) { layer.sync(options.models || []); modelsLoaded = true; boundsCache.clear(); } + meshes.length = 0; + built.scene.traverseVisible(o => { if (o instanceof THREE.Mesh) meshes.push(o); }); + // Include meshes hidden by a previous frame's stage toggle. + const found = new Set(meshes); + for (const mesh of originals.keys()) if (!found.has(mesh)) meshes.push(mesh); + for (const mesh of meshes) if (!originals.has(mesh)) { originals.set(mesh, mesh.material); visibility.set(mesh, mesh.visible); } + const [shaded, wire, led] = styles; + for (let i = 0; i < frames.length; i++) { + const f = frames[i]; signal.throwIfAborted(); + for (const mesh of meshes) { const screen = !!mesh.userData.sliceId; mesh.visible = visibility.get(mesh)! && (screen || f.stage); mesh.material = f.style === 'wireframe' ? wire : screen ? led : shaded; } + built.scene.updateMatrixWorld(true); + let bounds = boundsCache.get(f.stage); + if (!bounds) { + bounds = new THREE.Box3(); + for (const mesh of meshes) if (mesh.visible) { if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox(); if (mesh.geometry.boundingBox) bounds.union(mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld)); } + boundsCache.set(f.stage, bounds); + } + if (bounds.isEmpty()) throw new Error('No visible geometry is available for this view.'); + const center = bounds.getCenter(new THREE.Vector3()), size = bounds.getSize(new THREE.Vector3()), radius = Math.max(.1, size.length()); + const direction = new THREE.Vector3(...VIEW_DIRECTIONS[f.view]).normalize(); + const aspect = (f.w - 4) / (f.h - 19); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, radius * .001, radius * 8); + camera.position.copy(center).addScaledVector(direction, radius * 2); + if (Math.abs(direction.y) > .99) camera.up.set(0, 0, direction.y > 0 ? -1 : 1); + camera.lookAt(center); camera.updateMatrixWorld(true); + const cameraBounds = new THREE.Box3(); + for (const x of [bounds.min.x, bounds.max.x]) for (const y of [bounds.min.y, bounds.max.y]) for (const z of [bounds.min.z, bounds.max.z]) cameraBounds.expandByPoint(new THREE.Vector3(x, y, z).applyMatrix4(camera.matrixWorldInverse)); + const projected = cameraBounds.getSize(new THREE.Vector3()); + const halfH = Math.max(projected.y / 2, projected.x / (2 * aspect), .05) * 1.08; + const halfView = halfH / f.viewZoom; + const dx = f.panX / 100 * halfView * aspect * 2, dy = f.panY / 100 * halfView * 2; + camera.left = -halfView * aspect + dx; camera.right = halfView * aspect + dx; camera.top = halfView + dy; camera.bottom = -halfView + dy; camera.updateProjectionMatrix(); + // 150 dpi at the frame's actual printed size; capped to avoid giant GPU targets. + const width = Math.min(2800, Math.round((f.w - 4) / 25.4 * 150)), height = Math.min(2000, Math.round(width / aspect)); + renderer!.setSize(width, height, false); renderer!.render(built.scene, camera); + const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; + const ctx = canvas.getContext('2d')!; ctx.drawImage(renderer!.domElement, 0, 0); + const fontSize = Math.max(14, width / (f.w - 4) * f.fontSize * 25.4 / 72); ctx.font = `600 ${fontSize}px ${fontFamily}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const occupied: { x: number; y: number; w: number }[] = []; + for (const [id, mesh] of built.meshesBySlice) if (mesh.visible) { + const p = mesh.geometry.boundingBox!.getCenter(new THREE.Vector3()).applyMatrix4(mesh.matrixWorld).project(camera); + if (p.x < -1 || p.x > 1 || p.y < -1 || p.y > 1 || p.z < -1 || p.z > 1) continue; + const x = (p.x + 1) / 2 * width, y = (1 - p.y) / 2 * height, label = String(ids[id] || options.slices.findIndex(s => s.id === id) + 1).padStart(2, '0'); + const labelWidth = ctx.measureText(label).width + fontSize * .8; + let labelY = y; + for (let attempt = 0; attempt < 24 && occupied.some(b => Math.abs(b.x - x) < (b.w + labelWidth) / 2 + 4 && Math.abs(b.y - labelY) < fontSize * 1.7); attempt++) labelY = Math.max(fontSize, Math.min(height - fontSize, y + (attempt % 2 ? -1 : 1) * (Math.floor(attempt / 2) + 1) * fontSize * 1.8)); + if (labelY !== y) { ctx.strokeStyle = '#596165'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, labelY); ctx.stroke(); } + occupied.push({ x, y: labelY, w: labelWidth }); + ctx.fillStyle = '#263336'; ctx.fillRect(x - labelWidth / 2, labelY - fontSize * .7, labelWidth, fontSize * 1.4); ctx.fillStyle = '#ffffff'; ctx.fillText(label, x, labelY); + } + const blob = await new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error('Unable to capture view.')), 'image/png')); + signal.throwIfAborted(); + images[f.id] = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); }); + canvas.width = canvas.height = 1; + await new Promise(resolve => setTimeout(resolve, 16)); + } + return images; + } finally { + running = false; + if (disposed) cleanup(); + } + }, + }; +} diff --git a/app/technical-plots.css b/app/technical-plots.css new file mode 100644 index 0000000..19cd059 --- /dev/null +++ b/app/technical-plots.css @@ -0,0 +1,53 @@ +.technical-plots{min-width:0;min-height:0;height:100%;display:grid;grid-template-columns:248px minmax(0,1fr) 288px;background:var(--surface-app,#111416);color:var(--text-primary,#f4f7f8);font:500 11px/16px Geist,Arial,sans-serif;overflow:hidden} +.technical-plots[data-focused=true]{grid-template-columns:0 minmax(0,1fr) 288px}.technical-plots[data-focused=true] .plots-tools{visibility:hidden} +.plots-tools,.plots-inspector{min-width:0;overflow:auto;background:var(--surface-panel,#171a1c);border-right:1px solid var(--border-section,#343a3d)}.plots-inspector{border-left:1px solid var(--border-section,#343a3d)} +.technical-plots h2{font-size:12px;padding:12px;margin:0;border-bottom:1px solid var(--border-section,#343a3d)}.technical-plots h3{font-size:10px;color:var(--text-muted,#849095);letter-spacing:.08em;margin:0 0 8px} +.technical-plots section{padding:12px;border-bottom:1px solid var(--border-section,#343a3d)}.technical-plots button,.technical-plots input,.technical-plots select,.technical-plots textarea{box-sizing:border-box;max-width:100%;font:inherit;color:inherit;background:var(--surface-control,#202426);border:1px solid var(--border-control,#41484b);border-radius:0;min-height:30px;padding:5px 8px} +.technical-plots button{cursor:pointer}.technical-plots button:disabled{opacity:.4;cursor:default}.technical-plots button:hover:not(:disabled){background:var(--surface-hover,#272c2e)}.technical-plots button.active{background:var(--surface-selected,#303638);border-color:var(--accent,#d4dcd6)}.technical-plots :focus-visible{outline:1px solid var(--accent,#d4dcd6);outline-offset:2px} +.plots-tools section>button,.plots-inspector section>button,.technical-plots section>select{display:block;width:100%;margin:4px 0;text-align:left;overflow:hidden;text-overflow:ellipsis}.plots-tools button span{display:inline-block;color:var(--text-muted,#849095);margin-right:8px;font-variant-numeric:tabular-nums} +.technical-plots label{display:block;color:var(--text-secondary,#aab3b6);margin:8px 0;text-transform:capitalize}.technical-plots label input,.technical-plots label select,.technical-plots label textarea{display:block;width:100%;margin-top:4px;text-transform:none}.technical-plots textarea{resize:vertical;user-select:text}.technical-plots input{user-select:text;min-width:0;width:100%} +.plots-row,.plots-measurements{display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-top:4px}.plots-check{display:flex!important;gap:8px;align-items:center}.plots-check input{width:16px!important;margin:0!important} +.plots-main{display:flex;flex-direction:column;min-width:0;min-height:0}.plots-toolbar{display:flex;gap:8px;align-items:center;min-height:42px;padding:4px 12px;background:var(--surface-section,#191c1e);border-bottom:1px solid var(--border-section,#343a3d)}.plots-spacer{flex:1}.plots-toolbar>span:first-child{white-space:nowrap}.plots-viewport{flex:1;min-height:0;overflow:auto;background:#252a2c;padding:24px;box-sizing:border-box}.plots-page-space{position:relative;margin:auto;flex:none}.plots-paper{width:420mm;height:297mm;transform-origin:top left;position:relative;box-shadow:0 2px 18px #0005}.plots-paper>svg{pointer-events:none;user-select:none}.plots-status{min-height:32px;padding:8px 12px;border-top:1px solid var(--border-section,#343a3d);color:var(--text-secondary,#aab3b6)} +.plots-layout-overlay{position:absolute;inset:0} +[data-plots=true]{grid-template-columns:62px minmax(0,1fr)!important}[data-plots=true]>aside,[data-plots=true]>section{display:none!important}[data-plots=true]>.technical-plots{grid-column:2;grid-row:1} +@media(max-width:1200px){.technical-plots{grid-template-columns:204px minmax(0,1fr) 248px}.plots-toolbar{gap:4px;padding:4px}.plots-toolbar>span:first-child{display:none}} + +.plots-sheet-list{margin-bottom:10px}.plots-sheet-list>button{position:relative;display:flex;align-items:center;gap:6px;width:100%;text-align:left;margin:0;border-bottom:0;height:32px}.plots-sheet-list>button:last-child{border-bottom:1px solid var(--border-control,#41484b)}.plots-sheet-list strong{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.plots-sheet-list .ui-icon{width:16px;height:16px}.plots-sheet-list .drop-before:before,.plots-sheet-list .drop-after:after{content:'';position:absolute;left:0;right:0;height:2px;background:var(--accent,#d4dcd6);z-index:2}.plots-sheet-list .drop-before:before{top:-1px}.plots-sheet-list .drop-after:after{bottom:-1px} +.plots-placement{position:absolute;inset:0;cursor:crosshair}.plots-ghost{position:absolute;border:2px dashed #506e75;pointer-events:none;box-sizing:border-box}.plots-compose{position:absolute;border:2px dashed #506e75;cursor:grab;touch-action:none;background:transparent}.plots-compose:active{cursor:grabbing} +.technical-plots .number-control{display:flex;height:32px;padding-right:28px}.technical-plots .number-control input{margin:0;border:0;padding:0 8px;min-height:0;background:transparent}.technical-plots .number-control em{margin:0;flex:0 0 26px;font-size:9px}.technical-plots .field-stepper button{min-height:0;padding:0}.technical-plots .number-field{margin:8px 0}.technical-plots .number-field>span:first-child{line-height:14px}.technical-plots .number-control .field-stepper{right:0}.technical-plots .stepper-actions{z-index:100} +.plots-modal-backdrop{position:fixed;inset:0;z-index:18000;background:#0009;display:grid;place-items:center;padding:24px}.plots-print-dialog{width:min(1180px,96vw);max-height:94vh;display:flex;flex-direction:column;background:var(--surface-panel,#171a1c);border:1px solid var(--border-control,#41484b);box-shadow:0 16px 70px #0008;outline:none}.plots-print-dialog header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border-section,#343a3d);padding-right:12px}.plots-print-dialog h2{border:0;padding:16px}.plots-print-body{display:grid;grid-template-columns:240px minmax(0,1fr);min-height:0;overflow:auto}.plots-print-body>aside{padding:18px;border-right:1px solid var(--border-section,#343a3d)}.plots-print-view{padding:24px;background:#252a2c;min-width:0}.plots-print-page{aspect-ratio:420/297;background:white;box-shadow:0 3px 15px #0005}.plots-print-page>svg{width:100%;height:100%}.plots-print-view nav{display:flex;justify-content:center;align-items:center;gap:16px;margin-top:16px}.plots-print-dialog footer{padding:12px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border-section,#343a3d)}.plots-print-error{color:var(--amber,#e4b26d)} + +.technical-plots .number-control em{text-transform:none}.technical-plots input[type=checkbox]{accent-color:var(--accent,#d4dcd6)} + +.plots-print-dialog{padding:0;color:inherit;margin:auto}.plots-print-dialog::backdrop{background:#0009}.plots-print-dialog:not([open]){display:none} + +.plots-status { height: 36px; min-height: 36px; flex: 0 0 36px; box-sizing: border-box; overflow: hidden; white-space: nowrap; } +.plots-frame-control { position: absolute; pointer-events: auto; } +.plots-frame-control .plots-frame-move { position: absolute; inset: 0; width: 100%; height: 100%; border: 1px dashed #506e75; background: transparent; cursor: move; touch-action: none; padding: 0; } +.plots-frame-control.selected .plots-frame-move { border: 2px solid #506e75; } +.plots-frame-move span { position: absolute; top: 0; left: 0; padding: 3px 6px; color: white; background: #506e75; } +.plots-frame-control .plots-frame-resize { position: absolute; bottom: -5px; right: -5px; width: 18px; height: 18px; min-height: 0; padding: 0; border: 2px solid white; background: #506e75; cursor: nwse-resize; touch-action: none; } +.plots-guides { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 4; overflow: visible; } +.plots-guides line, .plots-guides path { stroke: #9b5b2e; stroke-width: .3; fill: none; } +.plots-guides text { fill: #855027; stroke: white; stroke-width: 1.1; paint-order: stroke; font: 3px Arial; } +.plots-compose { overflow: hidden; background: white; } + +.technical-plots .plots-frame-move:hover:not(:disabled) { background: transparent; } + +.plots-layout-overlay[data-copying=true] .plots-frame-move { cursor: copy; } + +.technical-plots .plots-template-name{margin:14px 0 10px;text-transform:none;font-size:10px;color:var(--text-muted,#849095)} +.technical-plots .plots-template-name input{margin-top:6px;background:var(--surface-app,#111416);border-color:var(--border-section,#343a3d);color:var(--text-primary,#f4f7f8);font-size:11px;cursor:text;box-shadow:inset 0 1px 2px #0003} +.technical-plots .plots-template-name input:focus{border-color:var(--accent,#d4dcd6)} +.technical-plots .plots-template-files{margin-top:12px;padding-top:10px;border-top:1px solid var(--border-section,#343a3d)} + +.plots-text-formatting{margin:14px 0;padding-top:12px;border-top:1px solid var(--border-section,#343a3d)} +.plots-text-style{display:grid;grid-template-columns:repeat(3,1fr);gap:4px}.plots-text-style button{font-size:13px} + +/* Match the existing inspector scrollbars, scoped to Technical Plots. */ +.plots-tools,.plots-inspector,.plots-viewport,.technical-plots textarea{scrollbar-width:thin;scrollbar-color:#454b4e transparent;color-scheme:dark} +.plots-tools,.plots-inspector,.technical-plots textarea{scrollbar-gutter:stable} +.plots-tools::-webkit-scrollbar,.plots-inspector::-webkit-scrollbar,.plots-viewport::-webkit-scrollbar,.technical-plots textarea::-webkit-scrollbar{width:8px;height:8px} +.plots-tools::-webkit-scrollbar-track,.plots-inspector::-webkit-scrollbar-track,.plots-viewport::-webkit-scrollbar-track,.technical-plots textarea::-webkit-scrollbar-track{background:transparent} +.plots-tools::-webkit-scrollbar-thumb,.plots-inspector::-webkit-scrollbar-thumb,.plots-viewport::-webkit-scrollbar-thumb,.technical-plots textarea::-webkit-scrollbar-thumb{background:#454b4e} +.plots-tools::-webkit-scrollbar-corner,.plots-inspector::-webkit-scrollbar-corner,.plots-viewport::-webkit-scrollbar-corner,.technical-plots textarea::-webkit-scrollbar-corner{background:transparent} diff --git a/app/technical-plots.tsx b/app/technical-plots.tsx new file mode 100644 index 0000000..0f770a2 --- /dev/null +++ b/app/technical-plots.tsx @@ -0,0 +1,206 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { defaultPlotDocument, frame, plotWarnings, printSheets, readPlotDocument, sheet, VIEW_DIRECTIONS, type PlotDocument, type PlotFrame, type PlotSource, reorderSheets, viewSignature, viewLabel, uid } from './technical-plots-data'; +import { PlotPaper } from './technical-plots-paper'; +import { createPlotCaptureSession } from './technical-plots-scene'; +import type { SceneExportOptions } from './scene-export'; +import './technical-plots.css'; +import PlotNumberField from './plot-number-field'; +import UiIcon from './ui-icon'; +import { snapLayout, duplicatePosition, type Guide } from './plot-layout'; +import PlotViewFraming from './plot-view-framing'; +import { mapLabelLayout, screenDetailLayout } from './plot-map-labels'; +import PlotPrintDialog from './plot-print-dialog'; + +type PlotBridge = { printTechnicalPlots?: (payload: { pages: string[]; name: string; print: boolean; deviceName?: string; copies?: number }) => Promise<{ canceled?: boolean; error?: string; path?: string }>; saveExport?: (name: string, mime: string, data: Uint8Array, category: string) => Promise }; +const desktop = () => (window as unknown as { lo2sDesktop?: PlotBridge }).lo2sDesktop; +type Template = { name: string; document: PlotDocument }; +export default function TechnicalPlots({ document, onChange, source, scene, active, focused, exportRef, onNotice }: { document: PlotDocument; onChange: (document: PlotDocument) => void; source: PlotSource; scene: SceneExportOptions; active: boolean; focused: boolean; onNotice: (message: string) => void; exportRef: React.RefObject<((print?: boolean) => void) | null> }) { + const [index, setIndex] = useState(0), [selected, setSelected] = useState(''), [edit, setEdit] = useState(false), [zoom, setZoom] = useState(0); + const [images, setImages] = useState>({}), [busy, setBusy] = useState(false); + const setStatus = onNotice; + const [pending, setPending] = useState(null), [ghost, setGhost] = useState<{ x: number; y: number } | null>(null); + const [dragSheet, setDragSheet] = useState(''), [dropIndex, setDropIndex] = useState(null), [compose, setCompose] = useState(false); + const [printOpen, setPrintOpen] = useState(false), [copySource, setCopySource] = useState(''); + const cache = useRef>({}), running = useRef(false), failed = useRef(''); + const [library, setLibrary] = useState(() => { try { const stored = JSON.parse(localStorage.getItem('opticmesh.plot-templates.v1') || '[]'); return Array.isArray(stored) ? stored.slice(0, 20).map(v => ({ name: String(v.name).slice(0, 100), document: readPlotDocument(v.document) })) : []; } catch { return []; } }), [templateName, setTemplateName] = useState('My template'); + const capture = useRef(null); + const [continuation, setContinuation] = useState(0); + const [interaction, setInteraction] = useState(false), [draftFrame, setDraftFrame] = useState(null), [guides, setGuides] = useState([]); + const session = useRef<{ scene: SceneExportOptions; capture: ReturnType } | null>(null); + const capturedFrames = useRef>({}); + const viewport = useRef(null), [available, setAvailable] = useState({ width: 800, height: 700 }); + const file = useRef(null), logo = useRef(null); + const current = document.sheets[Math.min(index, document.sheets.length - 1)], element = draftFrame?.id === selected ? draftFrame : current.frames.find(f => f.id === selected); + const views = document.sheets.flatMap(s => s.frames.filter(f => f.kind === 'view')); + const frameKey = (f: PlotFrame) => JSON.stringify([document.fontFamily, viewSignature(f)]); + const signature = JSON.stringify(views.map(f => [f.id, frameKey(f)])); + const stale = views.some(f => cache.current[f.id]?.key !== frameKey(f) || cache.current[f.id]?.scene !== scene || !images[f.id]); + const printable = useMemo(() => printSheets(document, source), [document, source]); + const warnings = [...plotWarnings(document, source), ...document.sheets.flatMap(s => s.frames.filter(f => f.kind === 'pattern' && source.slices[f.screen] && !screenDetailLayout(f, source.slices[f.screen]).fits).map(() => `${s.title}: enlarge the screen detail frame or reduce its text size to fit the dimensions.`)), ...document.sheets.flatMap(s => s.frames.filter(f => (f.kind === 'input' || f.kind === 'output') && mapLabelLayout(f, source).overflow).map(() => `${s.title}: enlarge the map frame or reduce its text size to fit all slice coordinates.`))]; + const scale = zoom || Math.max(.1, Math.min((available.width - 48) / 1587.402, (available.height - 48) / 1122.52)); + useEffect(() => { if (!active || !viewport.current) return; const observer = new ResizeObserver(([entry]) => setAvailable({ width: entry.contentRect.width, height: entry.contentRect.height })); observer.observe(viewport.current); return () => observer.disconnect(); }, [active]); + useEffect(() => () => { capture.current?.abort(); session.current?.capture.dispose(); }, []); + const updateSheet = (patch: Partial) => onChange({ ...document, sheets: document.sheets.map(s => s.id === current.id ? { ...s, ...patch } : s) }); + const updateFrame = (patch: Partial) => updateSheet({ frames: current.frames.map(f => f.id === selected ? { ...f, ...patch, ...(patch.view && patch.view !== f.view ? { viewZoom: 1, panX: 0, panY: 0 } : {}), ...(patch.view && f.autoTitle ? { title: viewLabel(patch.view) } : {}) } : f) }); + const addSheet = (kind: Parameters[0]) => { onChange({ ...document, sheets: [...document.sheets, sheet(kind)] }); setIndex(document.sheets.length); setSelected(''); }; + async function refresh(all = true) { + if (running.current) return null; + const requested = views.filter(f => all || cache.current[f.id]?.key !== frameKey(f) || cache.current[f.id]?.scene !== scene || !images[f.id]); + if (!requested.length) return images; + running.current = true; setBusy(true); + const controller = new AbortController(); capture.current = controller; + try { + if (session.current?.scene !== scene) { session.current?.capture.dispose(); session.current = { scene, capture: createPlotCaptureSession(scene) }; } + const result = await session.current.capture.capture(requested, controller.signal, Object.fromEntries(source.slices.map((s, i) => [s.id, i + 1])), document.fontFamily); + controller.signal.throwIfAborted(); + for (const f of requested) capturedFrames.current[f.id] = f; + for (const f of requested) cache.current[f.id] = { key: frameKey(f), scene }; + const next = { ...images, ...result }; setImages(previous => ({ ...previous, ...result })); failed.current = ''; + if (all) setStatus('Scene views updated.'); return next; + } catch (error) { if (!controller.signal.aborted) { failed.current = signature; setStatus(error instanceof Error ? error.message : 'Unable to prepare scene views.'); } return null; } + finally { setBusy(false); running.current = false; capture.current = null; } + } + useEffect(() => { + if (!active || busy || interaction || draftFrame || !stale || failed.current === signature) return; + const timer = setTimeout(() => void refresh(false), 350); + return () => clearTimeout(timer); + }); + useEffect(() => { failed.current = ''; }, [scene, signature]); + useEffect(() => { + if (!active) { capture.current?.abort(); session.current?.capture.dispose(); session.current = null; } + }, [active]); + useEffect(() => { + if (!pending && !compose) return; + const escape = (e: KeyboardEvent) => { if (e.key === 'Escape') { setPending(null); setGhost(null); setGuides([]); setCompose(false); setCopySource(''); } }; + window.addEventListener('keydown', escape); return () => window.removeEventListener('keydown', escape); + }, [pending, compose]); + async function exportPages(print = false) { + if (busy) { setStatus('Preparing scene views. Please wait a moment.'); return; } + if (warnings.length) { setStatus(warnings[0]); return; } + const ready = stale ? await refresh(false) : images; if (!ready) return; + if (print) { setPrintOpen(true); return; } + await sendPages(ready); + } + async function sendPages(ready: Record, deviceName?: string, copies = 1, pageIndex?: number) { + const bridge = desktop(); if (!bridge?.printTechnicalPlots) { setStatus('Open the local desktop version to export PDF or print.'); return false; } + setBusy(true); setStatus(deviceName ? 'Sending A3 sheets to printer…' : 'Preparing A3 PDF…'); + try { + const pages = printable.map((s, i) => renderToStaticMarkup()); + const result = await bridge.printTechnicalPlots({ pages: pageIndex === undefined ? pages : [pages[pageIndex]], name: document.projectTitle || source.name, print: !!deviceName, deviceName, copies }); + setStatus(result.error || (result.canceled ? 'Cancelled.' : deviceName ? 'Sent to printer.' : 'PDF saved.')); + return !result.error && !result.canceled; + } catch (error) { setStatus(error instanceof Error ? error.message : 'Export failed.'); return false; } finally { setBusy(false); } + } + useEffect(() => { if (active) exportRef.current = exportPages; return () => { if (active) exportRef.current = null; }; }); + function saveTemplate() { + const name = templateName.trim(); if (!name) return; + const clean = readPlotDocument({ ...document, projectTitle: '', author: '', revision: 'A', date: '', notes: '' }); + const next = [...library.filter(t => t.name !== name), { name, document: clean }].slice(-20); + try { localStorage.setItem('opticmesh.plot-templates.v1', JSON.stringify(next)); setLibrary(next); setStatus('Template saved to your library.'); } catch { setStatus('Template library is full. Export the template to a file.'); } + } + async function exportTemplate() { + const bridge = desktop(); if (!bridge?.saveExport) return setStatus('Open the desktop version to export templates.'); + try { await bridge.saveExport(`${templateName || 'Plot template'}.json`, 'application/json', new TextEncoder().encode(JSON.stringify({ ...document, projectTitle: '', author: '', revision: 'A', date: '', notes: '' }, null, 2)), 'scene3d'); setStatus('Template export finished.'); } catch { setStatus('Unable to export template.'); } + } + async function readTemplate(input: File) { try { if (input.size > 4000000) throw new Error('Template is too large (4 MB maximum).'); const next = readPlotDocument(JSON.parse(await input.text())); onChange(next); setIndex(0); setSelected(''); setStatus('Template applied.'); } catch (e) { setStatus(e instanceof Error ? e.message : 'Invalid template.'); } } + async function readLogo(input: File) { + try { + if (!['image/png', 'image/jpeg'].includes(input.type) || input.size > 15000000) throw new Error('Choose a PNG or JPG logo, up to 15 MB.'); + const bitmap = await createImageBitmap(input); const canvas = window.document.createElement('canvas'); const factor = Math.min(1, 1200 / Math.max(bitmap.width, bitmap.height)); canvas.width = Math.round(bitmap.width * factor); canvas.height = Math.round(bitmap.height * factor); canvas.getContext('2d')!.drawImage(bitmap, 0, 0, canvas.width, canvas.height); bitmap.close(); + const data = canvas.toDataURL('image/png'); if (data.length > 3000000) throw new Error('Choose a smaller logo.'); onChange({ ...document, logo: data }); + } catch (e) { setStatus(e instanceof Error ? e.message : 'Unable to read logo.'); } + } + function commitCopy(sourceId: string, copy: PlotFrame) { + if (images[sourceId]) { + setImages(previous => ({ ...previous, [copy.id]: images[sourceId] })); + if (cache.current[sourceId]) cache.current[copy.id] = cache.current[sourceId]; + if (capturedFrames.current[sourceId]) capturedFrames.current[copy.id] = capturedFrames.current[sourceId]; + } + updateSheet({ frames: [...current.frames, copy] }); + setSelected(copy.id); setCopySource(''); + } + function duplicateFrame() { + if (!element || draftFrame || pending || compose || interaction) return; + if (current.frames.length >= 24) { setStatus('A sheet can contain up to 24 frames. Add another sheet to continue.'); return; } + const copy = { ...element, id: uid() }, position = duplicatePosition(element, current.frames, document.grid, document.snap); + setEdit(true); setContinuation(0); + if (position) commitCopy(element.id, { ...copy, ...position }); + else { setCopySource(element.id); setPending(copy); setGhost(null); setStatus('No clear space beside this frame. Click on the sheet to place its copy.'); } + } + function drag(event: React.PointerEvent, f: PlotFrame, resize = false) { + if (!edit || event.button !== 0) return; + event.preventDefault(); event.stopPropagation(); + const copying = event.altKey && !resize; + if (copying && current.frames.length >= 24) { setStatus('A sheet can contain up to 24 frames. Add another sheet to continue.'); return; } + const start = copying ? { ...f, id: uid() } : f; + setSelected(start.id); if (copying) { setCopySource(f.id); setDraftFrame(start); } + const startX = event.clientX, startY = event.clientY, target = event.currentTarget; + target.focus({ preventScroll: true }); target.setPointerCapture(event.pointerId); let next = start; let moved = false; + const move = (e: PointerEvent) => { + const dx = (e.clientX - startX) / (3.77952756 * scale), dy = (e.clientY - startY) / (3.77952756 * scale); + moved ||= Math.abs(e.clientX - startX) + Math.abs(e.clientY - startY) > 3; + const raw = resize ? { ...start, w: f.w + dx, h: f.h + dy } : { ...start, x: f.x + dx, y: f.y + dy }; + const result = snapLayout(raw, current.frames, document.grid, document.snap, 6 / (3.77952756 * scale), resize); + next = { ...f, ...result.frame }; setDraftFrame(next); setGuides(result.guides); + }; + const stop = (e: PointerEvent) => { + target.removeEventListener('pointermove', move); target.removeEventListener('pointerup', stop); target.removeEventListener('pointercancel', stop); + if (e.type !== 'pointercancel' && copying && moved) commitCopy(f.id, next); + else if (copying) setSelected(f.id); + else if (e.type !== 'pointercancel' && moved) updateSheet({ frames: current.frames.map(item => item.id === f.id ? next : item) }); + setDraftFrame(null); setGuides([]); setCopySource(''); + }; + target.addEventListener('pointermove', move); target.addEventListener('pointerup', stop); target.addEventListener('pointercancel', stop); + } + function place(event: React.PointerEvent) { + if (!pending) return; + const rect = event.currentTarget.getBoundingClientRect(); + const result = snapLayout({ ...pending, x: (event.clientX - rect.left) / rect.width * 420, y: (event.clientY - rect.top) / rect.height * 297 }, current.frames, document.grid, document.snap, 6 / (3.77952756 * scale)); + setGhost({ x: result.frame.x, y: result.frame.y }); setGuides(result.guides); + } + if (!active) return null; + const continuations = printable.filter(s => s.id.startsWith(current.id + "-")); + const previewSheet = continuations[Math.min(continuation, continuations.length - 1)]; + const firstPrinted = printable.findIndex(s => s.id === `${current.id}-0`); + const layoutFrames = draftFrame ? current.frames.some(f => f.id === draftFrame.id) ? current.frames.map(f => f.id === draftFrame.id ? draftFrame : f) : [...current.frames, draftFrame] : current.frames; + const previewImages = copySource && draftFrame && images[copySource] ? { ...images, [draftFrame.id]: images[copySource] } : images; + return
{ + if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'd' || event.altKey || event.shiftKey) return; + if ((event.target as HTMLElement).closest('input, textarea, select, [contenteditable="true"], dialog, [role="dialog"]')) return; + event.preventDefault(); event.stopPropagation(); if (!event.repeat) duplicateFrame(); + }}> +