diff --git a/.agents/skills b/.agents/skills new file mode 120000 index 00000000..e5da19da --- /dev/null +++ b/.agents/skills @@ -0,0 +1 @@ +../.opencode/skills \ No newline at end of file diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 00000000..e5da19da --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.opencode/skills \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2338f058..432dda6f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,13 @@ generated/ !.opencode/agents/** !.opencode/skills/ !.opencode/skills/** +# Skill entry points for Claude Code and Codex: symlinks to .opencode/skills +!.claude/ +.claude/* +!.claude/skills +!.agents/ +.agents/* +!.agents/skills .opencode/node_modules/ .opencode/package.json .opencode/package-lock.json @@ -103,3 +110,6 @@ samples/Weather/**/*.lua !samples/Weather/config/*.lua Error /samples/Book/Images + +# Blender auto-backups next to committed .blend files +*.blend1 diff --git a/.opencode/skills/orca-3d-scene/SKILL.md b/.opencode/skills/orca-3d-scene/SKILL.md new file mode 100644 index 00000000..f08fed78 --- /dev/null +++ b/.opencode/skills/orca-3d-scene/SKILL.md @@ -0,0 +1,174 @@ +--- +name: orca-3d-scene +description: Set up, drive and debug an ORCA project that renders a 3D scene (SceneKit): package.lua, Screen > Viewport3D > Scene > Camera, Mesh/Material/Shader XML, custom uniforms as property types, blend and draw order, AnimationClip players scrubbed from input values, prefabs, running the app and verifying frames. Use for any 3D app, HMI display or cluster project in ORCA, whether exported from Blender or written by hand. +--- + +# ORCA 3D Scene + +`apps/cluster-demo/` is the worked example of everything here. Generated +assets come from `$orca-blender-pipeline`; this skill covers what the engine +expects and how to run it. Facts below were verified against the source in +`plugins/SceneKit/`, `source/renderer/` and `source/core/components/`. + +## Project layout + +``` +apps// + package.lua manifest (package.xml is NOT read any more) + Screens/.xml Screen > Viewport3D > Scene > Camera + content + Scenes/.xml Node3D prefab with the content (generated) + Meshes/.xml (+ .mesh, gitignored) + Shaders/.xml with VertexShader / FragmentShader text + Materials/.xml + Animations/.xml + Animations.lua index +``` + +`package.lua` essentials (see `apps/cluster-demo/package.lua`): + +```lua +Name = "ClusterDemo" -- asset path prefix +StartupScreen = "ClusterDemo/Screens/Cluster" +WindowWidth, WindowHeight = 1920, 720 +ScreenLibrary = { IsExternal = true } +SystemMessages = { { Message = "KeyDown", Key = "q", Command = "return" }, ... } -- field is Message, not Name +EnginePlugins = { { Name = "orca.UIKit" }, { Name = "orca.SceneKit" } } -- SceneKit must be listed +PropertyTypes = { { Name = "Level", Category = "Uniform", DataType = "Float" }, ... } +``` + +Asset paths are `/` without extension; `.xml` is tried +by the loader. Run with `build/bin/orca apps/`. + +## Scene XML + +```xml + + + + + + + + +``` + +- Coordinate system: right-handed, Y up, camera looks down its local -Z. +- `LayoutTransform="tx ty tz rx ry rz sx sy sz"`; rotation in degrees, + applied X then Y then Z. Atoms `LayoutTransformTranslation/Rotation/Scale` + are what animations target. `RenderTransform*` is an extra transform on top. +- The camera must be a **direct child of the Scene** (lookup is by name among + direct children). `Fov` is the full angle; use `FovType="Yfov"` with the + vertical FOV. `ProjectionType` is ignored (always perspective). +- `Viewport3D` renders its first direct `Scene` child and always draws to the + full window; make it full-size. +- `Model3D Mesh="

/Meshes/" Material="

/Materials/"`. ``'s + own `Material` is ignored. `#Quad` and `#Plane` are built-in mesh sources + for smoke tests. +- `PrefabView3D Prefab="..."` loads a file as its child (async, after Start). + +## Shaders and materials + +```xml + + +in vec3 a_position; in vec2 a_texcoord0; out vec2 v_texcoord0; +uniform mat4 u_modelViewProjectionTransform; +void main() { v_texcoord0 = a_texcoord0; gl_Position = u_modelViewProjectionTransform * vec4(a_position, 1.0); } + + +in vec2 v_texcoord0; +void main() { FragColor = vec4(Tint.rgb, Intensity); } + + + +``` + +- The engine prepends `#version 330 core` (`300 es` on QNX/WebGL), declares + `out lowp vec4 ;`, and emits `uniform ;` for **every + property set on the Shader element**. Declare custom uniforms there + (`Uniform.Name="default"`) so colours get the sRGB->linear conversion; do not + redeclare them in GLSL. Text may be plain or CDATA; escape `<` as `<` + in plain text. +- Attributes: `a_position`, `a_normal`, `a_tangent`, `a_binormal`, `a_color`, + `a_texcoord0..4`. Builtins: `u_modelViewProjectionTransform`, + `u_modelTransform`, `u_viewTransform`, `u_projectionTransform`, + `u_normalTransform`, `u_opacity`, `u_time`, `u_viewport`, + `u_cameraPosition`, `u_texture`, `u_color`. +- Custom uniforms are attached properties `Category.Name`; the uniform name + is the part after the dot. Each name needs a `PropertyTypes` entry in + `package.lua` (`DataType` `Float` or `Color`; vectors also work now but + keep to these two). Values set on the `Model3D` override the material's, + which is how per-node animation works. +- Built-in material colours (`Diffuse`, `Emissive`, ...) are also available + as uniforms by name. `BlendIntensity` is forced to the node opacity. +- `BlendMode` on the Material: `Opaque`, `Alpha`, `Additive` + (`SRC_ALPHA, ONE`), `PremultipliedAlpha`; `AlphaAutomatic` inherits the + Shader's `BlendMode`. A `Material.BlendMode` attribute on the `Model3D` + overrides both. Shader `DepthTestFunction` / `DepthWriteEnabled` apply too. +- Draw order is tree order with depth test `LessOrEqual` and depth writes on + by default: order transparent siblings back to front. Culling is off. +- Every `Model3D` loads its own Material and compiles its own Shader (no + cache); keep the material count reasonable. + +## Animation and binding + +```xml + + + + + + + + +... + + + +``` + +- An `` element is **an object of its own**; curve `Path`s + are relative to it (`..` allowed). `Keyframe` fields are `Time`, `Value` + (vec4), `TangentMode` (`1` = linear; default bezier uses `InSlope`, + `OutSlope`, `InWeight`, `OutWeight`). +- `Property` is the short name of any property on the target: transform + atoms, `Opacity`, or a uniform such as `Level` if the node declares it + (`Uniform.Level="0.5"` on the Model3D). +- **Scrubbing:** setting `CurrentTime` on a player that is not playing + applies the clip at that time. Bind an input to a gauge with + `player.CurrentTime = Duration * (value - Min) / (Max - Min)` using the + entries in `Animations.lua`. Another clip can do it too: curves with + `Property="CurrentTime"` targeting the players + (`apps/cluster-demo/Demo/FillAll.xml`). +- Playback: `AutoplayEnabled="true"`, `Mode="Loop|PingPong"` on the clip, + `Speed`, `Play/Stop/Pause/Resume` messages. Time advances once per painted + frame. + +## Run and verify + +```bash +build/bin/orca apps/ 2>&1 | tee /tmp/orca.log +``` + +Errors to grep for: `shader compilation failed`, `program link failed`, +`model not found`, `is not a MESH file`, `Could not set field`, `Unknown +property`, `Unknown element type`, `Failed to load prefab`. + +There is no screenshot command. On macOS capture only the orca window: + +```bash +WID=$(swift -e 'import CoreGraphics; for w in CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as! [[String: Any]] where (w["kCGWindowOwnerName"] as? String) == "orca" { print(w["kCGWindowNumber"]!) }' | head -1) +screencapture -x -o -l "$WID" frame.png +``` + +Take two captures a second apart and `cmp` them to prove animation runs. +`-test=` runs headless Lua against the engine without a window +(see `tests/test_animations.lua` for clips driven from Lua). + +## Engine limits to design around + +- `TextBlock3D` renders white unless `Color` is set, lays out at 512 px, and + cannot use custom shaders; baked text meshes are the alternative. +- No orthographic cameras; `Viewport3D` ignores its own rect. +- Vertex colours load (`a_color`, UINT8 normalised) when the FBX had them. +- Rebuild after editing `.cgen`: `make modules && make`. diff --git a/.opencode/skills/orca-3d-scene/agents/openai.yaml b/.opencode/skills/orca-3d-scene/agents/openai.yaml new file mode 100644 index 00000000..29f80aa6 --- /dev/null +++ b/.opencode/skills/orca-3d-scene/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "ORCA 3D Scene" + short_description: "Set up, drive and debug ORCA 3D scene projects" + default_prompt: "Use $orca-3d-scene to create or debug an ORCA project with a SceneKit scene: package.lua, Viewport3D/Scene/Camera XML, shaders and materials, animation players bound to input values, and verifying the running app." diff --git a/.opencode/skills/orca-blender-pipeline/SKILL.md b/.opencode/skills/orca-blender-pipeline/SKILL.md new file mode 100644 index 00000000..961b7fac --- /dev/null +++ b/.opencode/skills/orca-blender-pipeline/SKILL.md @@ -0,0 +1,153 @@ +--- +name: orca-blender-pipeline +description: Build 3D display content (instrument clusters, HMI scenes, dashboards) in Blender so that tools/blender-export.py turns it into a runnable ORCA project: shader node groups that become GLSL, UV-driven materials, value-driven "fill-up" animations, and the export, regeneration and verification loop. Use when authoring or changing a Blender scene for ORCA, exporting one, or debugging why an export looks or behaves differently in the engine. +--- + +# ORCA Blender Pipeline + +The pipeline is: Blender scene -> `tools/blender-export.py` (inside Blender) +-> FBX + `orca-fbx` for meshes, XML for everything else -> `build/bin/orca`. +`apps/cluster-demo/` is the reference: `blender/build_cluster.py` builds the +whole scene procedurally and documents every convention below in code. + +Read `$orca-3d-scene` for the engine side (project layout, XML, binding, +running). This skill is about what the Blender file must look like. + +## Setup + +```bash +brew install lz4 freetype jpeg libpng # engine deps on macOS +export PKG_CONFIG_PATH=/opt/homebrew/opt/jpeg/lib/pkgconfig:$PKG_CONFIG_PATH +make # engine +make orca-fbx # needs /Applications/Autodesk/FBX SDK/2020.2 +``` + +Blender 4.x/5.x. The exporter runs inside Blender (it uses `bpy`); the +Blender MCP server or `blender -b file.blend --python ...` both work. + +## Scene conventions + +- **Axes and units.** Author Z up with the camera on -Y looking +Y. The + exporter rotates everything into ORCA's Y-up, camera-looks-down--Z space, so + never pre-rotate for the engine. Units are arbitrary but keep them sane + (the cluster uses 1 unit = 10 cm); the camera frustum defines the display. +- **Camera = display.** One camera, perspective, `sensor_fit='HORIZONTAL'`, + and `scene.render.resolution_x/y` set to the target display. The exporter + derives the vertical FOV from these; the window size comes from the same + resolution. +- **Hierarchy.** Empties are fine as groups; every object's local transform is + exported as-is (`LayoutTransform`). Put a pivot where something rotates or + scales at runtime (needle origin on the dial centre, bar origin on its left + end). +- **Layering.** The engine draws siblings in tree order with depth writes on. + The exporter sorts siblings back-to-front by camera distance, so give + stacked transparent elements distinct depths (small Z offsets per layer) and + keep glows behind the things they glow around. +- **Panels that face the eye.** Place side panels on a cylinder around the + camera and yaw them towards it; nothing in the engine requires this, it is + what makes a wide display read correctly. +- **Text.** Bake text to mesh in the build script (`meshes.new_from_object`) + so the export is complete; keep the string, font and size in custom + properties (`orca_text*`) for a later `TextBlock3D` replacement. Fonts: the + cluster uses system DIN; any TTF works. +- **Collections.** Put everything exportable in one collection and export it + with `--collection`; keep runtime-driven objects in a sub-collection so they + are easy to find. + +## Materials are shader node groups + +A material is **one group node named `Shader` plus a blend mode**. The group +is the fragment shader: it reads UV0 and its inputs (the uniforms) and +outputs `Color` and `Alpha`. The material wraps that in Emission plus +Transparent (alpha) or Add Shader (additive) so EEVEE previews it faithfully. + +Rules the exporter depends on: + +- The group needs a Text datablock named `.frag` holding the GLSL + fragment body (`in vec2 v_texcoord0;` plus `void main()` writing + `FragColor`). ORCA adds the `#version`, the `out` declaration and the + `uniform` lines itself, so do not write `#version`, do not declare + `FragColor`, and any `uniform` lines you keep for readability are stripped on + export. An optional `.vert` overrides the default vertex stage. +- Keep the node graph to Math, Mix, Separate XYZ, Vector Math and Texture + Coordinate so group and GLSL stay equivalent; write both at the same time. +- Inputs: floats and colours only (ORCA passes other uniform types badly). + Name inputs so they do not shadow node properties (`Intensity`, `Tint`, not + `Opacity`, `Color`). Colour inputs are vec4 in GLSL; use `.rgb`. +- Colours are authored as display sRGB and converted to linear by the engine + (`pow 2.2`) before your shader runs, and the framebuffer is sRGB. Blender + with the `Standard` view transform shows the same result, so author colours + in the material's node inputs and preview with `Standard`. +- UV0 carries the shader coordinate: U along an element, V across it; for + rings U is the angle and V the radius. Build geometry with UVs that mean + something (sweep fraction, segment index, distance along a stroke). +- `material["orca_blend"]` is `opaque`, `alpha` or `additive`. +- No lights, no textures needed; if an Image Texture node is present its + image is copied to `Images/` and set as the material `Texture` (untested + path). + +The six groups in the cluster (`IC_Flat`, `IC_Gradient`, `IC_Sweep`, +`IC_Segments`, `IC_SpunMetal`, `IC_RadialGlow`) cover flat colour, ramps and +glows, gauge fills, segmented bars, fake brushed metal and soft blobs; start +from them. + +## Animations are value-driven clips + +Each gauge gets one action that fills it from empty (frame 0) to full (last +frame) with **linear** keys. The runtime never plays it: it sets the player's +`CurrentTime`, so `time / duration` is the reading. Author with +`scene.render.fps = 100` and frames 0..100 so the clip is exactly 1 s long and +time == normalised value. + +- One action may hold several slots: the needle's rotation (object) and the + sweep's `Level` (the material node tree's `nodes["Shader"].inputs[...]`) + live in the same `Gauge_Rev` action. +- Put the signal on the action: `action["orca_signal"]`, `orca_min`, + `orca_max`. The exporter writes them to `Animations.lua`. +- Supported channels: object `location`, `rotation_euler`, `scale` and shader + input `default_value`. Non-linear keys are baked per frame. +- Animated shader inputs are exported per node (each Model3D gets its own + `Uniform.`), because every node loads its own material instance. + +## Export + +```python +import runpy +runpy.run_path("tools/blender-export.py")["export"]( + "apps/", collection="") +``` + +or headless (untested): `blender -b scene.blend --python tools/blender-export.py -- apps/ --collection `. + +Outputs: `Meshes/` (binary `.mesh` are gitignored; the `` XML next to +them is committed), `Shaders/`, `Materials/`, `Animations/` + `Animations.lua`, +`Scenes/.xml` (prefab, regenerated every time), and once only +`Screens/.xml` and `package.lua` (app-owned afterwards; the export +result prints the camera element to copy if it changed, and lists property +types missing from `package.lua`). + +Save the `.blend` and the build script under `apps//blender/`. + +## Verify + +1. `build/bin/orca apps/` and read stderr: `shader compilation + failed`, `model not found`, `Could not set field`, `Unknown element` are the + usual export mistakes. +2. Compare against Blender's render through the display camera. Expect the + same layout and colours; a washed-out image means colours were exported + linear instead of sRGB or a uniform was not declared on the Shader. +3. For motion, capture the window twice and compare (see `$orca-3d-scene`). + +## Common mistakes + +- Blown-out or missing glow: wrong `orca_blend`, or two layers at the same + depth drawn in the wrong order. +- Mirrored or sideways scene: FBX exported with other axis settings than the + exporter uses (`-Z` forward, `Y` up, bake space transform). +- A uniform renders black/zero: its name is missing from `PropertyTypes` in + `package.lua` (the export result lists these), or it collides with an + engine property name. +- Gauge pose does not change: the clip's curve `Path` does not resolve; paths + are relative to the player object (`../../` from + `Animations/`), and `Keyframe` fields are `Time`, `Value`, + `TangentMode` (capitalised). diff --git a/.opencode/skills/orca-blender-pipeline/agents/openai.yaml b/.opencode/skills/orca-blender-pipeline/agents/openai.yaml new file mode 100644 index 00000000..0ad249be --- /dev/null +++ b/.opencode/skills/orca-blender-pipeline/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "ORCA Blender Pipeline" + short_description: "Author Blender scenes that export to ORCA" + default_prompt: "Use $orca-blender-pipeline to build or change a Blender scene (shader node groups, UV-driven materials, fill-up animations) and export it with tools/blender-export.py into an ORCA project." diff --git a/AGENTS.md b/AGENTS.md index d07e4feb..1b4589d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,10 @@ ## Agents and skills Agents are classic implementation roles under `.opencode/agents/`; skills are -task playbooks under `.opencode/skills/`. Choose the task skill first, then use -an agent when delegation or specialist ownership is useful. +task playbooks under `.opencode/skills/`. The same skills are reachable as +`.claude/skills/` (Claude Code) and `.agents/skills/` (Codex); both are +symlinks, so edit only `.opencode/skills/`. Choose the task skill first, then +use an agent when delegation or specialist ownership is useful. | Typical task | Skill | Agent | |---|---|---| @@ -14,6 +16,8 @@ an agent when delegation or specialist ownership is useful. | Write Lua or MoonScript programs | `orca-lua-authoring` | `lua-developer` | | Write UI/package XML | `orca-xml-authoring` | `xml-ui-developer` | | Define images, textures, shaders, font families, or scenes | `orca-xml-assets-scenes` | `renderer-developer` | +| Build or debug a 3D app: SceneKit scene XML, shaders/materials, animation players bound to inputs, running it | `orca-3d-scene` | `renderer-developer` | +| Author a Blender scene for ORCA and export it (`tools/blender-export.py`, `orca-fbx`) | `orca-blender-pipeline` | `renderer-developer` | | Bind Lua to XML with controllers, events, or data bindings | `orca-controller-binding` | `lua-developer` or `xml-ui-developer` | | Add or update tests | `orca-testing` | `test-writer` | | Apply C or CSS conventions | `orca-c-style` or `orca-css` | relevant implementation agent | @@ -68,6 +72,7 @@ After `.cgen` changes, run `make modules`. | Lua API | generated Lua reference, then `docs/LUA_API.md` | | UI / layout / XML / style | `docs/UI_SYSTEM.md` | | Text rendering / fonts / glyphs | `docs/renderer-internals.md` → *Text Rendering Pipeline* | +| 3D scene / Blender export | `apps/cluster-demo/` (reference project), `docs/animations.md` | | C style | `docs/CODING_STYLE.md` | ## Working rules diff --git a/Makefile b/Makefile index 49ac90b4..cdadf880 100644 --- a/Makefile +++ b/Makefile @@ -80,7 +80,7 @@ else Q = @ endif -.PHONY: default all CLEAN directories unite buildlib buildplugins app platform example weather install codegen-host codegen-clean codegen-sample modules modules-c-preview modules-c-diff test test-headless test-properties test-styles test-filesystem test-message-registry test-trigger-actions test-editor test-xml-serialization test-text-layout test-stack-layout test-grid-layout test-interaction test-adventure-header-hit test-node test-state-manager test-animations test-timers test-styles-lua test-body test-console-view test-widget test-router test-application test-geometry test-parsers test-object-hierarchy test-object-retention test-async test-tabbar test-tab-interaction test-layout test-listbox +.PHONY: default all CLEAN directories unite orca-fbx buildlib buildplugins app platform example weather install codegen-host codegen-clean codegen-sample modules modules-c-preview modules-c-diff test test-headless test-properties test-styles test-filesystem test-message-registry test-trigger-actions test-editor test-xml-serialization test-text-layout test-stack-layout test-grid-layout test-interaction test-adventure-header-hit test-node test-state-manager test-animations test-timers test-styles-lua test-body test-console-view test-widget test-router test-application test-geometry test-parsers test-object-hierarchy test-object-retention test-async test-tabbar test-tab-interaction test-layout test-listbox default: directories modules unite all: default @@ -128,6 +128,13 @@ app: modules platform unite: directories buildunite buildlib buildplugins app copyshare +# FBX -> .mesh converter used by tools/blender-export.py. Optional: needs the Autodesk FBX SDK. +FBX_SDK ?= /Applications/Autodesk/FBX SDK/2020.2 +orca-fbx: directories + $(CXX) -std=c++17 -O2 -w -I. -Igenerated -I"$(FBX_SDK)/include" tools/fbx-import.cpp \ + "$(FBX_SDK)/lib/clang/release/libfbxsdk.a" -framework CoreFoundation -liconv -lxml2 -lz \ + -o $(BINDIR)/orca-fbx + %.o: %.c $(HEADERS) | modules $(Q)$(CC) $(CFLAGS) -c $< -o $(addprefix $(OBJECTDIR)/,$(notdir $@)) diff --git a/QUICKSTART.md b/QUICKSTART.md index 66a3500c..0af2cbe0 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -7,7 +7,7 @@ This guide will help you get started with the ORCA Framework by creating your fi 1. [Prerequisites](#prerequisites) 2. [Creating a New Project](#creating-a-new-project) 3. [Scene File Formats](#scene-file-formats) -4. [Package.xml Specification](#packagexml-specification) +4. [Package.lua Specification](#packagelua-specification) 5. [Building and Running](#building-and-running) 6. [Examples](#examples) @@ -26,7 +26,7 @@ To create a new ORCA project, you'll need to set up the following basic structur ``` MyProject/ -├── package.xml # Project configuration file +├── package.lua # Project configuration file ├── Screens/ # Directory for screen definitions │ └── Application.xml # Main application screen └── Images/ # Directory for images (optional) @@ -40,7 +40,7 @@ mkdir MyProject cd MyProject ``` -2. Create a `package.xml` file (see [Package.xml Specification](#packagexml-specification)) +2. Create a `package.lua` file (see [Package.lua Specification](#packagelua-specification)) 3. Create a `Screens` directory for your UI definitions: ```sh @@ -182,116 +182,140 @@ class HomePage extends ui.StackView - Define routes with pattern matching - Apply CSS classes with `apply` method -## Package.xml Specification +## Package.lua Specification -The `package.xml` file is the entry point for your ORCA project. It defines project metadata, libraries, and system messages. +The `package.lua` file is the entry point for your ORCA project. It defines project metadata, libraries, and system messages. It is the only project manifest the runtime reads; `package.xml` is no longer loaded. ### Basic Structure -```xml - - - - +`package.lua` is a Lua file in which each global assignment sets a property of the project: + +```lua +Name = "ProjectName" +StartupScreen = "ProjectName/Screens/ScreenName" +-- Project configuration ``` -### Required Attributes +### Required Properties - `Name`: The name of your project -- `StartupScreen`: The path to the initial screen to load (format: `ProjectName/Path/To/Screen`) +- `StartupScreen`: The path to the initial screen to load (format: `ProjectName/Path/To/Screen`). Script-driven applications set `StartupViewController` (and optionally `StartupRoute`) instead, as `samples/Weather` and `samples/Banking` do. + +### Optional Properties -### Optional Elements +#### WindowWidth / WindowHeight -#### ProjectReferenceLibrary +Initial window size in pixels: + +```lua +WindowWidth = 1024 +WindowHeight = 768 +``` + +#### ProjectReferences Define external project references and library paths: -```xml - - applications - assets - lib/routing - +```lua +ProjectReferences = { + { Name = "views", Path = "views" }, + { Name = "assets", Path = "assets" }, + { Name = "model", Path = "model" }, +} ``` #### ScreenLibrary Define screen libraries (can be external): -```xml - +```lua +ScreenLibrary = { IsExternal = true } ``` #### ImageLibrary Define image libraries: -```xml - +```lua +ImageLibrary = { IsExternal = true } ``` -#### PropertyTypeLibrary +#### PropertyTypes Define custom property types: -```xml - - - +```lua +PropertyTypes = { + { Name = "Title", Category = "NavigationBar", DataType = "String" }, +} ``` -#### MessageLibrary +#### SystemMessages Define system message handlers: -```xml - - return - return - window:refresh() - +```lua +SystemMessages = { + { Message = "KeyDown", Key = "q", Command = "return" }, + { Message = "WindowClosed", Command = "return" }, + { Message = "RequestReload", Command = "window:refresh()" }, +} ``` -**Note**: The attribute names are case-insensitive in ORCA. You may see both `Message`/`Key` (capital) and `message`/`key` (lowercase) in different samples. Both work correctly. +**Note**: Field names are case-sensitive. A `SystemMessage` has the fields `Message`, `Key`, and `Command`. + +#### EnginePlugins + +Load engine plugins so their classes are available: + +```lua +EnginePlugins = { + { Name = "orca.UIKit" }, +} +``` ### Complete Examples #### Example 1: Minimal Project -```xml - - - - - - return - return - - +```lua +Name = "Example" +StartupScreen = "Example/Screens/Application" +WindowWidth = 1024 +WindowHeight = 768 +ScreenLibrary = { IsExternal = true } +ImageLibrary = { IsExternal = true } +SystemMessages = { + { Message = "KeyDown", Key = "q", Command = "return" }, + { Message = "WindowClosed", Command = "return" }, +} +EnginePlugins = { + { Name = "orca.UIKit" }, +} ``` -#### Example 2: Complex Project with Libraries +#### Example 2: Script Project with Project References -```xml - - - - applications - assets - lib/appwrite - lib/html - lib/routing - model - root - config - tailwind - - - return - return - window:refresh() - - +This is `samples/Banking/package.lua`: + +```lua +Name = "Banking" +StartupViewController = "Banking/App" +StartupRoute = "/" +WindowWidth = 375 +WindowHeight = 812 +ProjectReferences = { + { Name = "views", Path = "views" }, + { Name = "assets", Path = "assets" }, + { Name = "model", Path = "model" }, + { Name = "appwrite", Path = "lib/appwrite" }, + { Name = "config", Path = "config" }, +} +SystemMessages = { + { Message = "KeyDown", Key = "q", Command = "return" }, + { Message = "WindowClosed", Command = "return" }, + { Message = "RequestReload", Command = "window:refresh()" }, +} ``` ## Building and Running @@ -308,14 +332,14 @@ git submodule update --init --recursive make ``` -3. **Run your project**: +3. **Run your project** by passing the project directory (the one containing `package.lua`): ```sh -make run PROJECT=/path/to/your/project/package.xml +build/bin/orca /path/to/your/project ``` -Or for a quick clean build and run: +For example, to run the bundled sample: ```sh -make andrun PROJECT=/path/to/your/project/package.xml +build/bin/orca samples/Example ``` ## Examples @@ -325,21 +349,25 @@ make andrun PROJECT=/path/to/your/project/package.xml **Directory structure:** ``` HelloWorld/ -├── package.xml +├── package.lua └── Screens/ └── Main.xml ``` -**package.xml:** -```xml - - - - - return - return - - +**package.lua:** +```lua +Name = "HelloWorld" +StartupScreen = "HelloWorld/Screens/Main" +WindowWidth = 1024 +WindowHeight = 768 +ScreenLibrary = { IsExternal = true } +SystemMessages = { + { Message = "KeyDown", Key = "q", Command = "return" }, + { Message = "WindowClosed", Command = "return" }, +} +EnginePlugins = { + { Name = "orca.UIKit" }, +} ``` **Screens/Main.xml:** @@ -425,12 +453,12 @@ class MyApp extends Application - **Lua** is ideal for programmatic UI construction and complex logic - **MoonScript** offers a cleaner syntax for Lua and is great for application-level code - You can mix and match formats in the same project -- Use the MessageLibrary to handle system events like key presses and window closing -- External libraries can be referenced via ProjectReferenceLibrary for code organization +- Use `SystemMessages` in `package.lua` to handle system events like key presses and window closing +- External libraries can be referenced via `ProjectReferences` for code organization ## Common Issues -- **Startup screen not found**: Ensure the StartupScreen path in package.xml matches your actual file structure +- **Startup screen not found**: Ensure the `StartupScreen` path in `package.lua` matches your actual file structure - **Images not loading**: Check that image paths are relative to the project root (e.g., `ProjectName/Images/filename`) - **Build errors**: Make sure all dependencies are installed (see README.md) diff --git a/apps/cluster-demo/Animations.lua b/apps/cluster-demo/Animations.lua new file mode 100644 index 00000000..9efba8c3 --- /dev/null +++ b/apps/cluster-demo/Animations.lua @@ -0,0 +1,9 @@ +-- Generated by tools/blender-export.py. One entry per exported animation clip. +-- Scrub a clip by setting CurrentTime on its player node: time = Duration * (value - Min) / (Max - Min). +return { + Gauge_Boost = { Player = "Animations/Gauge_Boost", Duration = 1, Signal = "boost_bar", Min = 0, Max = 2.5 }, + Gauge_Oil = { Player = "Animations/Gauge_Oil", Duration = 1, Signal = "oil_temp_c", Min = 50, Max = 150 }, + Gauge_Range = { Player = "Animations/Gauge_Range", Duration = 1, Signal = "fuel_level", Min = 0, Max = 1 }, + Gauge_Rev = { Player = "Animations/Gauge_Rev", Duration = 1, Signal = "rpm", Min = 0, Max = 8000 }, + Gauge_Water = { Player = "Animations/Gauge_Water", Duration = 1, Signal = "water_temp_c", Min = 50, Max = 130 }, +} diff --git a/apps/cluster-demo/Animations/Gauge_Boost.xml b/apps/cluster-demo/Animations/Gauge_Boost.xml new file mode 100644 index 00000000..1047a5ef --- /dev/null +++ b/apps/cluster-demo/Animations/Gauge_Boost.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/cluster-demo/Animations/Gauge_Oil.xml b/apps/cluster-demo/Animations/Gauge_Oil.xml new file mode 100644 index 00000000..870f9b25 --- /dev/null +++ b/apps/cluster-demo/Animations/Gauge_Oil.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/cluster-demo/Animations/Gauge_Range.xml b/apps/cluster-demo/Animations/Gauge_Range.xml new file mode 100644 index 00000000..9b997312 --- /dev/null +++ b/apps/cluster-demo/Animations/Gauge_Range.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/cluster-demo/Animations/Gauge_Rev.xml b/apps/cluster-demo/Animations/Gauge_Rev.xml new file mode 100644 index 00000000..6e8f063d --- /dev/null +++ b/apps/cluster-demo/Animations/Gauge_Rev.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/cluster-demo/Animations/Gauge_Water.xml b/apps/cluster-demo/Animations/Gauge_Water.xml new file mode 100644 index 00000000..d6498a6e --- /dev/null +++ b/apps/cluster-demo/Animations/Gauge_Water.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/cluster-demo/Demo/DriveCycle.xml b/apps/cluster-demo/Demo/DriveCycle.xml new file mode 100644 index 00000000..aeacb27b --- /dev/null +++ b/apps/cluster-demo/Demo/DriveCycle.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/cluster-demo/Demo/FillAll.xml b/apps/cluster-demo/Demo/FillAll.xml new file mode 100644 index 00000000..f83cfa53 --- /dev/null +++ b/apps/cluster-demo/Demo/FillAll.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/cluster-demo/Materials/IC_AmbientCool.xml b/apps/cluster-demo/Materials/IC_AmbientCool.xml new file mode 100644 index 00000000..2541166e --- /dev/null +++ b/apps/cluster-demo/Materials/IC_AmbientCool.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_AmbientDial.xml b/apps/cluster-demo/Materials/IC_AmbientDial.xml new file mode 100644 index 00000000..40216ee3 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_AmbientDial.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_AmbientRed.xml b/apps/cluster-demo/Materials/IC_AmbientRed.xml new file mode 100644 index 00000000..db2a2cfb --- /dev/null +++ b/apps/cluster-demo/Materials/IC_AmbientRed.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_BarTrack.xml b/apps/cluster-demo/Materials/IC_BarTrack.xml new file mode 100644 index 00000000..453dc722 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_BarTrack.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_Bar_Boost.xml b/apps/cluster-demo/Materials/IC_Bar_Boost.xml new file mode 100644 index 00000000..18b56df5 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_Bar_Boost.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_Bar_Oil.xml b/apps/cluster-demo/Materials/IC_Bar_Oil.xml new file mode 100644 index 00000000..6caceb9c --- /dev/null +++ b/apps/cluster-demo/Materials/IC_Bar_Oil.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_Bar_Water.xml b/apps/cluster-demo/Materials/IC_Bar_Water.xml new file mode 100644 index 00000000..7fdf67d1 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_Bar_Water.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_BezelGlow.xml b/apps/cluster-demo/Materials/IC_BezelGlow.xml new file mode 100644 index 00000000..7808ddcb --- /dev/null +++ b/apps/cluster-demo/Materials/IC_BezelGlow.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_Card.xml b/apps/cluster-demo/Materials/IC_Card.xml new file mode 100644 index 00000000..699ef0ab --- /dev/null +++ b/apps/cluster-demo/Materials/IC_Card.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_CardEdge.xml b/apps/cluster-demo/Materials/IC_CardEdge.xml new file mode 100644 index 00000000..06127893 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_CardEdge.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_Chevron.xml b/apps/cluster-demo/Materials/IC_Chevron.xml new file mode 100644 index 00000000..1c3d4af4 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_Chevron.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_ChevronGlow.xml b/apps/cluster-demo/Materials/IC_ChevronGlow.xml new file mode 100644 index 00000000..7fde7f81 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_ChevronGlow.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_ChipEdge.xml b/apps/cluster-demo/Materials/IC_ChipEdge.xml new file mode 100644 index 00000000..1d56e3c4 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_ChipEdge.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_DialBezel.xml b/apps/cluster-demo/Materials/IC_DialBezel.xml new file mode 100644 index 00000000..d4c5868d --- /dev/null +++ b/apps/cluster-demo/Materials/IC_DialBezel.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_DialFace.xml b/apps/cluster-demo/Materials/IC_DialFace.xml new file mode 100644 index 00000000..54550ea5 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_DialFace.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_Green.xml b/apps/cluster-demo/Materials/IC_Green.xml new file mode 100644 index 00000000..c88022f7 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_Green.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_GreenDim.xml b/apps/cluster-demo/Materials/IC_GreenDim.xml new file mode 100644 index 00000000..defb09d7 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_GreenDim.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_Grey.xml b/apps/cluster-demo/Materials/IC_Grey.xml new file mode 100644 index 00000000..28ecc969 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_Grey.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_Hub.xml b/apps/cluster-demo/Materials/IC_Hub.xml new file mode 100644 index 00000000..29100bc9 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_Hub.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_HubGroove.xml b/apps/cluster-demo/Materials/IC_HubGroove.xml new file mode 100644 index 00000000..cdb72f77 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_HubGroove.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_HubRing.xml b/apps/cluster-demo/Materials/IC_HubRing.xml new file mode 100644 index 00000000..2de0f375 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_HubRing.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_Needle.xml b/apps/cluster-demo/Materials/IC_Needle.xml new file mode 100644 index 00000000..5382ec12 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_Needle.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_NeedleGlow.xml b/apps/cluster-demo/Materials/IC_NeedleGlow.xml new file mode 100644 index 00000000..f0c5a8f7 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_NeedleGlow.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_PanelPlate.xml b/apps/cluster-demo/Materials/IC_PanelPlate.xml new file mode 100644 index 00000000..5ca11b8e --- /dev/null +++ b/apps/cluster-demo/Materials/IC_PanelPlate.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_RangeFill.xml b/apps/cluster-demo/Materials/IC_RangeFill.xml new file mode 100644 index 00000000..02bccdaa --- /dev/null +++ b/apps/cluster-demo/Materials/IC_RangeFill.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_Red.xml b/apps/cluster-demo/Materials/IC_Red.xml new file mode 100644 index 00000000..567f4414 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_Red.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_RedDim.xml b/apps/cluster-demo/Materials/IC_RedDim.xml new file mode 100644 index 00000000..ec4b5b7c --- /dev/null +++ b/apps/cluster-demo/Materials/IC_RedDim.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_RevRim.xml b/apps/cluster-demo/Materials/IC_RevRim.xml new file mode 100644 index 00000000..71f923bd --- /dev/null +++ b/apps/cluster-demo/Materials/IC_RevRim.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_RevRimGlow.xml b/apps/cluster-demo/Materials/IC_RevRimGlow.xml new file mode 100644 index 00000000..64aa59dc --- /dev/null +++ b/apps/cluster-demo/Materials/IC_RevRimGlow.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_RevSweep.xml b/apps/cluster-demo/Materials/IC_RevSweep.xml new file mode 100644 index 00000000..9df73a5f --- /dev/null +++ b/apps/cluster-demo/Materials/IC_RevSweep.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_TickMinor.xml b/apps/cluster-demo/Materials/IC_TickMinor.xml new file mode 100644 index 00000000..6955e954 --- /dev/null +++ b/apps/cluster-demo/Materials/IC_TickMinor.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Materials/IC_White.xml b/apps/cluster-demo/Materials/IC_White.xml new file mode 100644 index 00000000..c3e5400e --- /dev/null +++ b/apps/cluster-demo/Materials/IC_White.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Ambient_DialCool.xml b/apps/cluster-demo/Meshes/Ambient_DialCool.xml new file mode 100644 index 00000000..f5870779 --- /dev/null +++ b/apps/cluster-demo/Meshes/Ambient_DialCool.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Ambient_DialRed.xml b/apps/cluster-demo/Meshes/Ambient_DialRed.xml new file mode 100644 index 00000000..78160a95 --- /dev/null +++ b/apps/cluster-demo/Meshes/Ambient_DialRed.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Ambient_LeftRed.xml b/apps/cluster-demo/Meshes/Ambient_LeftRed.xml new file mode 100644 index 00000000..8364b3ad --- /dev/null +++ b/apps/cluster-demo/Meshes/Ambient_LeftRed.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Ambient_RightRed.xml b/apps/cluster-demo/Meshes/Ambient_RightRed.xml new file mode 100644 index 00000000..053da4eb --- /dev/null +++ b/apps/cluster-demo/Meshes/Ambient_RightRed.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Gear_Mode.xml b/apps/cluster-demo/Meshes/Gear_Mode.xml new file mode 100644 index 00000000..b30c649f --- /dev/null +++ b/apps/cluster-demo/Meshes/Gear_Mode.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Gear_Number.xml b/apps/cluster-demo/Meshes/Gear_Number.xml new file mode 100644 index 00000000..8d00e857 --- /dev/null +++ b/apps/cluster-demo/Meshes/Gear_Number.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_Best.xml b/apps/cluster-demo/Meshes/Lap_Best.xml new file mode 100644 index 00000000..193e2b74 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_Best.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_BestLabel.xml b/apps/cluster-demo/Meshes/Lap_BestLabel.xml new file mode 100644 index 00000000..dce9d8e1 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_BestLabel.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_Car.xml b/apps/cluster-demo/Meshes/Lap_Car.xml new file mode 100644 index 00000000..2f350ed1 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_Car.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_Chevron.xml b/apps/cluster-demo/Meshes/Lap_Chevron.xml new file mode 100644 index 00000000..412e8351 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_Chevron.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_ChevronGlow.xml b/apps/cluster-demo/Meshes/Lap_ChevronGlow.xml new file mode 100644 index 00000000..82398ac8 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_ChevronGlow.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_Circuit.xml b/apps/cluster-demo/Meshes/Lap_Circuit.xml new file mode 100644 index 00000000..ec73e852 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_Circuit.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_CircuitDone.xml b/apps/cluster-demo/Meshes/Lap_CircuitDone.xml new file mode 100644 index 00000000..0d6ffac5 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_CircuitDone.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_Count.xml b/apps/cluster-demo/Meshes/Lap_Count.xml new file mode 100644 index 00000000..7537bb55 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_Count.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_Delta.xml b/apps/cluster-demo/Meshes/Lap_Delta.xml new file mode 100644 index 00000000..6bfef297 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_Delta.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_Last.xml b/apps/cluster-demo/Meshes/Lap_Last.xml new file mode 100644 index 00000000..9462fba3 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_Last.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_LastLabel.xml b/apps/cluster-demo/Meshes/Lap_LastLabel.xml new file mode 100644 index 00000000..42c24799 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_LastLabel.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_MapCard.xml b/apps/cluster-demo/Meshes/Lap_MapCard.xml new file mode 100644 index 00000000..9606cc4d --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_MapCard.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_MapCard_Edge.xml b/apps/cluster-demo/Meshes/Lap_MapCard_Edge.xml new file mode 100644 index 00000000..3749f7fd --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_MapCard_Edge.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_Plate.xml b/apps/cluster-demo/Meshes/Lap_Plate.xml new file mode 100644 index 00000000..b2d65256 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_Plate.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_Time.xml b/apps/cluster-demo/Meshes/Lap_Time.xml new file mode 100644 index 00000000..7825c84f --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_Time.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_TimesCard.xml b/apps/cluster-demo/Meshes/Lap_TimesCard.xml new file mode 100644 index 00000000..5536b30c --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_TimesCard.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Lap_TimesCard_Edge.xml b/apps/cluster-demo/Meshes/Lap_TimesCard_Edge.xml new file mode 100644 index 00000000..a0b16cb7 --- /dev/null +++ b/apps/cluster-demo/Meshes/Lap_TimesCard_Edge.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Bezel.xml b/apps/cluster-demo/Meshes/Rev_Bezel.xml new file mode 100644 index 00000000..90415d42 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Bezel.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_BezelGlow.xml b/apps/cluster-demo/Meshes/Rev_BezelGlow.xml new file mode 100644 index 00000000..87150d84 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_BezelGlow.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Face.xml b/apps/cluster-demo/Meshes/Rev_Face.xml new file mode 100644 index 00000000..3dfd46b3 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Face.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Hub.xml b/apps/cluster-demo/Meshes/Rev_Hub.xml new file mode 100644 index 00000000..27d620a3 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Hub.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_HubGroove.xml b/apps/cluster-demo/Meshes/Rev_HubGroove.xml new file mode 100644 index 00000000..dcbeef26 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_HubGroove.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_HubRing.xml b/apps/cluster-demo/Meshes/Rev_HubRing.xml new file mode 100644 index 00000000..294b07cd --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_HubRing.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Needle.xml b/apps/cluster-demo/Meshes/Rev_Needle.xml new file mode 100644 index 00000000..9b03947b --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Needle.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_NeedleGlow.xml b/apps/cluster-demo/Meshes/Rev_NeedleGlow.xml new file mode 100644 index 00000000..9d778c69 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_NeedleGlow.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Num0.xml b/apps/cluster-demo/Meshes/Rev_Num0.xml new file mode 100644 index 00000000..8a42af73 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Num0.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Num1.xml b/apps/cluster-demo/Meshes/Rev_Num1.xml new file mode 100644 index 00000000..6442dfd8 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Num1.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Num2.xml b/apps/cluster-demo/Meshes/Rev_Num2.xml new file mode 100644 index 00000000..b7e1f0a8 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Num2.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Num3.xml b/apps/cluster-demo/Meshes/Rev_Num3.xml new file mode 100644 index 00000000..d72f4a20 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Num3.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Num4.xml b/apps/cluster-demo/Meshes/Rev_Num4.xml new file mode 100644 index 00000000..9ab4d9e9 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Num4.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Num5.xml b/apps/cluster-demo/Meshes/Rev_Num5.xml new file mode 100644 index 00000000..801d9bf7 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Num5.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Num6.xml b/apps/cluster-demo/Meshes/Rev_Num6.xml new file mode 100644 index 00000000..85eb8d20 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Num6.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Num7.xml b/apps/cluster-demo/Meshes/Rev_Num7.xml new file mode 100644 index 00000000..f7cb04a2 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Num7.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Num8.xml b/apps/cluster-demo/Meshes/Rev_Num8.xml new file mode 100644 index 00000000..a1771598 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Num8.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_RedZone.xml b/apps/cluster-demo/Meshes/Rev_RedZone.xml new file mode 100644 index 00000000..4ff1c178 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_RedZone.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Rim.xml b/apps/cluster-demo/Meshes/Rev_Rim.xml new file mode 100644 index 00000000..aca0b49a --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Rim.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_RimGlow.xml b/apps/cluster-demo/Meshes/Rev_RimGlow.xml new file mode 100644 index 00000000..563b6f39 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_RimGlow.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Scale.xml b/apps/cluster-demo/Meshes/Rev_Scale.xml new file mode 100644 index 00000000..2a310239 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Scale.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Speed.xml b/apps/cluster-demo/Meshes/Rev_Speed.xml new file mode 100644 index 00000000..99daeb4c --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Speed.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_SpeedUnit.xml b/apps/cluster-demo/Meshes/Rev_SpeedUnit.xml new file mode 100644 index 00000000..3f18894e --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_SpeedUnit.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_Sweep.xml b/apps/cluster-demo/Meshes/Rev_Sweep.xml new file mode 100644 index 00000000..2dae18d5 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_Sweep.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_TicksFine.xml b/apps/cluster-demo/Meshes/Rev_TicksFine.xml new file mode 100644 index 00000000..e824d789 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_TicksFine.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_TicksMajor.xml b/apps/cluster-demo/Meshes/Rev_TicksMajor.xml new file mode 100644 index 00000000..d6ec47fa --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_TicksMajor.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_TicksMajorRed.xml b/apps/cluster-demo/Meshes/Rev_TicksMajorRed.xml new file mode 100644 index 00000000..547c93fa --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_TicksMajorRed.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Rev_TicksMinor.xml b/apps/cluster-demo/Meshes/Rev_TicksMinor.xml new file mode 100644 index 00000000..0c339142 --- /dev/null +++ b/apps/cluster-demo/Meshes/Rev_TicksMinor.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Turn_Left.xml b/apps/cluster-demo/Meshes/Turn_Left.xml new file mode 100644 index 00000000..f96fe4d8 --- /dev/null +++ b/apps/cluster-demo/Meshes/Turn_Left.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Turn_Right.xml b/apps/cluster-demo/Meshes/Turn_Right.xml new file mode 100644 index 00000000..b8a3a216 --- /dev/null +++ b/apps/cluster-demo/Meshes/Turn_Right.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_BOOST_Bar.xml b/apps/cluster-demo/Meshes/Vitals_BOOST_Bar.xml new file mode 100644 index 00000000..c1c25db2 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_BOOST_Bar.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_BOOST_Caption.xml b/apps/cluster-demo/Meshes/Vitals_BOOST_Caption.xml new file mode 100644 index 00000000..4d60ce42 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_BOOST_Caption.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_BOOST_Label.xml b/apps/cluster-demo/Meshes/Vitals_BOOST_Label.xml new file mode 100644 index 00000000..5da20be6 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_BOOST_Label.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_BOOST_Value.xml b/apps/cluster-demo/Meshes/Vitals_BOOST_Value.xml new file mode 100644 index 00000000..f8bc16ba --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_BOOST_Value.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_Chevron.xml b/apps/cluster-demo/Meshes/Vitals_Chevron.xml new file mode 100644 index 00000000..9c61ac52 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_Chevron.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_ChevronGlow.xml b/apps/cluster-demo/Meshes/Vitals_ChevronGlow.xml new file mode 100644 index 00000000..4ceb2621 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_ChevronGlow.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_ModeA.xml b/apps/cluster-demo/Meshes/Vitals_ModeA.xml new file mode 100644 index 00000000..8d5e0774 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_ModeA.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_ModeB.xml b/apps/cluster-demo/Meshes/Vitals_ModeB.xml new file mode 100644 index 00000000..59caeac6 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_ModeB.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_ModeCard.xml b/apps/cluster-demo/Meshes/Vitals_ModeCard.xml new file mode 100644 index 00000000..0301672b --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_ModeCard.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_ModeCard_Edge.xml b/apps/cluster-demo/Meshes/Vitals_ModeCard_Edge.xml new file mode 100644 index 00000000..1fbb5032 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_ModeCard_Edge.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_OIL_Bar.xml b/apps/cluster-demo/Meshes/Vitals_OIL_Bar.xml new file mode 100644 index 00000000..297b6ba9 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_OIL_Bar.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_OIL_Caption.xml b/apps/cluster-demo/Meshes/Vitals_OIL_Caption.xml new file mode 100644 index 00000000..b22c53cf --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_OIL_Caption.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_OIL_Label.xml b/apps/cluster-demo/Meshes/Vitals_OIL_Label.xml new file mode 100644 index 00000000..0d0fc31a --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_OIL_Label.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_OIL_Value.xml b/apps/cluster-demo/Meshes/Vitals_OIL_Value.xml new file mode 100644 index 00000000..1c42ba3e --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_OIL_Value.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_Plate.xml b/apps/cluster-demo/Meshes/Vitals_Plate.xml new file mode 100644 index 00000000..3bc05599 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_Plate.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_Range.xml b/apps/cluster-demo/Meshes/Vitals_Range.xml new file mode 100644 index 00000000..7c81bdbb --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_Range.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_RangeLabel.xml b/apps/cluster-demo/Meshes/Vitals_RangeLabel.xml new file mode 100644 index 00000000..4fef10e4 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_RangeLabel.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_RangeLevel.xml b/apps/cluster-demo/Meshes/Vitals_RangeLevel.xml new file mode 100644 index 00000000..61e5a460 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_RangeLevel.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_RangeTrack.xml b/apps/cluster-demo/Meshes/Vitals_RangeTrack.xml new file mode 100644 index 00000000..2151886c --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_RangeTrack.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_Selection.xml b/apps/cluster-demo/Meshes/Vitals_Selection.xml new file mode 100644 index 00000000..159b5018 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_Selection.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_Selection_Edge.xml b/apps/cluster-demo/Meshes/Vitals_Selection_Edge.xml new file mode 100644 index 00000000..fc6e9abf --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_Selection_Edge.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_WATER_Bar.xml b/apps/cluster-demo/Meshes/Vitals_WATER_Bar.xml new file mode 100644 index 00000000..fa9cd436 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_WATER_Bar.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_WATER_Caption.xml b/apps/cluster-demo/Meshes/Vitals_WATER_Caption.xml new file mode 100644 index 00000000..7b65a423 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_WATER_Caption.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_WATER_Label.xml b/apps/cluster-demo/Meshes/Vitals_WATER_Label.xml new file mode 100644 index 00000000..e77f709d --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_WATER_Label.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/Meshes/Vitals_WATER_Value.xml b/apps/cluster-demo/Meshes/Vitals_WATER_Value.xml new file mode 100644 index 00000000..76c8d805 --- /dev/null +++ b/apps/cluster-demo/Meshes/Vitals_WATER_Value.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/cluster-demo/README.md b/apps/cluster-demo/README.md new file mode 100644 index 00000000..c38aa4fe --- /dev/null +++ b/apps/cluster-demo/README.md @@ -0,0 +1,24 @@ +# Cluster demo + +A 1920x720 digital instrument cluster authored in Blender and exported to ORCA +with `tools/blender-export.py`. It is the reference project for the +`orca-blender-pipeline` and `orca-3d-scene` skills. + +```bash +make && make orca-fbx # engine + FBX converter (needs the Autodesk FBX SDK) +# regenerate the binary meshes (gitignored) and the other assets from Blender: +blender -b apps/cluster-demo/blender/cluster.blend --python tools/blender-export.py \ + -- apps/cluster-demo --collection InstrumentCluster +build/bin/orca apps/cluster-demo +``` + +- `blender/build_cluster.py` builds the whole scene procedurally (run it inside + Blender); `blender/cluster.blend` is its saved result. +- Every gauge is an `AnimationClip` in `Animations/`; the app poses a gauge by + setting its player's `CurrentTime` (0..1 = empty..full). `Animations.lua` + lists each clip's signal name and range. +- `Demo/FillAll.xml` (used by `Screens/Cluster.xml`) scrubs all gauges + together as a stand-in for vehicle signals; `Demo/DriveCycle.xml` is a more + car-like alternative. +- `Screens/Cluster.xml` and `package.lua` are app-owned; everything else is + regenerated by the exporter. diff --git a/apps/cluster-demo/Scenes/Cluster.xml b/apps/cluster-demo/Scenes/Cluster.xml new file mode 100644 index 00000000..3ac8e456 --- /dev/null +++ b/apps/cluster-demo/Scenes/Cluster.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/cluster-demo/Screens/Cluster.xml b/apps/cluster-demo/Screens/Cluster.xml new file mode 100644 index 00000000..074ac8ee --- /dev/null +++ b/apps/cluster-demo/Screens/Cluster.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/apps/cluster-demo/Shaders/IC_Flat.xml b/apps/cluster-demo/Shaders/IC_Flat.xml new file mode 100644 index 00000000..d6db982f --- /dev/null +++ b/apps/cluster-demo/Shaders/IC_Flat.xml @@ -0,0 +1,24 @@ + + + +in vec3 a_position; +in vec2 a_texcoord0; +out vec2 v_texcoord0; +uniform mat4 u_modelViewProjectionTransform; +void main() { + v_texcoord0 = a_texcoord0; + gl_Position = u_modelViewProjectionTransform * vec4(a_position, 1.0); +} + + +// IC_Flat: solid colour +// Mirrors the Blender node group "IC_Flat"; uniform names are its input names. +// ORCA prepends the #version line and declares the FragColor output; colour +// uniforms are vec4 display (sRGB) values and only .rgb is used. +in vec2 v_texcoord0; + +void main() { + FragColor = vec4(Tint.rgb, Intensity); +} + + diff --git a/apps/cluster-demo/Shaders/IC_Gradient.xml b/apps/cluster-demo/Shaders/IC_Gradient.xml new file mode 100644 index 00000000..a682c3d0 --- /dev/null +++ b/apps/cluster-demo/Shaders/IC_Gradient.xml @@ -0,0 +1,30 @@ + + + +in vec3 a_position; +in vec2 a_texcoord0; +out vec2 v_texcoord0; +uniform mat4 u_modelViewProjectionTransform; +void main() { + v_texcoord0 = a_texcoord0; + gl_Position = u_modelViewProjectionTransform * vec4(a_position, 1.0); +} + + +// IC_Gradient: three-stop colour ramp along U, fading at both ends of U and softly across V +// Mirrors the Blender node group "IC_Gradient"; uniform names are its input names. +// ORCA prepends the #version line and declares the FragColor output; colour +// uniforms are vec4 display (sRGB) values and only .rgb is used. +in vec2 v_texcoord0; + +void main() { + float u = v_texcoord0.x, v = v_texcoord0.y; + vec3 c = mix(Color0.rgb, Color1.rgb, clamp((u - Pos0) / (Pos1 - Pos0), 0.0, 1.0)); + c = mix(c, Color2.rgb, clamp((u - Pos1) / (Pos2 - Pos1), 0.0, 1.0)); + float fade = clamp(u / max(FadeIn, 1e-4), 0.0, 1.0) * clamp((1.0 - u) / max(FadeOut, 1e-4), 0.0, 1.0); + float edge = 1.0 - abs(2.0 * v - 1.0); + float soft = mix(1.0, edge * edge, Soft); + FragColor = vec4(c, Intensity * fade * soft); +} + + diff --git a/apps/cluster-demo/Shaders/IC_RadialGlow.xml b/apps/cluster-demo/Shaders/IC_RadialGlow.xml new file mode 100644 index 00000000..d5c9dc73 --- /dev/null +++ b/apps/cluster-demo/Shaders/IC_RadialGlow.xml @@ -0,0 +1,25 @@ + + + +in vec3 a_position; +in vec2 a_texcoord0; +out vec2 v_texcoord0; +uniform mat4 u_modelViewProjectionTransform; +void main() { + v_texcoord0 = a_texcoord0; + gl_Position = u_modelViewProjectionTransform * vec4(a_position, 1.0); +} + + +// IC_RadialGlow: soft round glow on a quad with UV 0..1 +// Mirrors the Blender node group "IC_RadialGlow"; uniform names are its input names. +// ORCA prepends the #version line and declares the FragColor output; colour +// uniforms are vec4 display (sRGB) values and only .rgb is used. +in vec2 v_texcoord0; + +void main() { + float d = clamp(length(v_texcoord0 - vec2(0.5)) * 2.0, 0.0, 1.0); + FragColor = vec4(Tint.rgb, Intensity * pow(1.0 - d, Power)); +} + + diff --git a/apps/cluster-demo/Shaders/IC_Segments.xml b/apps/cluster-demo/Shaders/IC_Segments.xml new file mode 100644 index 00000000..74247d5f --- /dev/null +++ b/apps/cluster-demo/Shaders/IC_Segments.xml @@ -0,0 +1,25 @@ + + + +in vec3 a_position; +in vec2 a_texcoord0; +out vec2 v_texcoord0; +uniform mat4 u_modelViewProjectionTransform; +void main() { + v_texcoord0 = a_texcoord0; + gl_Position = u_modelViewProjectionTransform * vec4(a_position, 1.0); +} + + +// IC_Segments: segmented bar: each segment has one U value, segments below Level are lit +// Mirrors the Blender node group "IC_Segments"; uniform names are its input names. +// ORCA prepends the #version line and declares the FragColor output; colour +// uniforms are vec4 display (sRGB) values and only .rgb is used. +in vec2 v_texcoord0; + +void main() { + float lit = v_texcoord0.x < Level ? 1.0 : 0.0; + FragColor = vec4(mix(ColorOff.rgb, ColorOn.rgb, lit), Intensity); +} + + diff --git a/apps/cluster-demo/Shaders/IC_SpunMetal.xml b/apps/cluster-demo/Shaders/IC_SpunMetal.xml new file mode 100644 index 00000000..d67639f1 --- /dev/null +++ b/apps/cluster-demo/Shaders/IC_SpunMetal.xml @@ -0,0 +1,30 @@ + + + +in vec3 a_position; +in vec2 a_texcoord0; +out vec2 v_texcoord0; +uniform mat4 u_modelViewProjectionTransform; +void main() { + v_texcoord0 = a_texcoord0; + gl_Position = u_modelViewProjectionTransform * vec4(a_position, 1.0); +} + + +// IC_SpunMetal: fake spun/brushed metal for rings and discs: conic highlights plus concentric grooves +// Mirrors the Blender node group "IC_SpunMetal"; uniform names are its input names. +// ORCA prepends the #version line and declares the FragColor output; colour +// uniforms are vec4 display (sRGB) values and only .rgb is used. +in vec2 v_texcoord0; + +void main() { + float u = v_texcoord0.x, v = v_texcoord0.y; + float conic = pow(abs(cos((u + Phase) * Lobes * 3.14159265)), Sharpness); + float ring = fract(sin(floor(v * Rings) * 12.9898) * 43758.5453); + float f = clamp(conic * (1.0 - RingContrast * (1.0 - ring)) + ring * RingContrast * 0.12, 0.0, 1.0); + float e = 2.0 * v - 1.0; + vec3 c = mix(ColorDark.rgb, ColorLight.rgb, f) * (1.0 - clamp(EdgeDark * e * e, 0.0, 1.0)); + FragColor = vec4(c, Intensity); +} + + diff --git a/apps/cluster-demo/Shaders/IC_Sweep.xml b/apps/cluster-demo/Shaders/IC_Sweep.xml new file mode 100644 index 00000000..2623de9c --- /dev/null +++ b/apps/cluster-demo/Shaders/IC_Sweep.xml @@ -0,0 +1,28 @@ + + + +in vec3 a_position; +in vec2 a_texcoord0; +out vec2 v_texcoord0; +uniform mat4 u_modelViewProjectionTransform; +void main() { + v_texcoord0 = a_texcoord0; + gl_Position = u_modelViewProjectionTransform * vec4(a_position, 1.0); +} + + +// IC_Sweep: gauge fill: U spans the whole scale, lit up to Level with a tail fading back over Tail +// Mirrors the Blender node group "IC_Sweep"; uniform names are its input names. +// ORCA prepends the #version line and declares the FragColor output; colour +// uniforms are vec4 display (sRGB) values and only .rgb is used. +in vec2 v_texcoord0; + +void main() { + float u = v_texcoord0.x, v = v_texcoord0.y; + float a = clamp((u - Level + Tail) / Tail, 0.0, 1.0); + float lit = u < Level ? 1.0 : 0.0; + float radial = 1.0 - InnerFade * (1.0 - v); + FragColor = vec4(mix(Color0.rgb, Color1.rgb, a), Intensity * a * a * lit * radial); +} + + diff --git a/apps/cluster-demo/blender/build_cluster.py b/apps/cluster-demo/blender/build_cluster.py new file mode 100644 index 00000000..6b9a0b05 --- /dev/null +++ b/apps/cluster-demo/blender/build_cluster.py @@ -0,0 +1,771 @@ +"""Digital instrument cluster "Supersport Reactor": realtime display-content scene. +Z up, 1 unit = 10 cm. + +Built for export to orca: + * every mesh is unlit; no lights, textures or post effects. Glow is geometry. + * a material is one shader node group (node name "Shader") plus a blend mode. + The group is the fragment shader: UV0 + its inputs (the uniforms) -> Color, Alpha. + Groups only use Math / Mix / Vector Math nodes, and each one has a matching + GLSL fragment in a Text datablock of the same name with a ".frag" suffix. + * UV0 carries the shader coordinates: U along an element, V across it + (for rings: U = angle / full turn, V = inner -> outer radius). + * material custom properties: orca_shader (group name), orca_blend + (opaque | alpha | additive). + * runtime-driven parts live in IC_Dynamic, pivot where they move, and carry an + "orca_binding" custom property. Text is baked to mesh; runtime text keeps + its string, font and size in custom properties. + * each gauge has a linear "fill-up" action (Gauge_*) from empty to full. The + runtime scrubs it: clip time / duration = the reading. "orca_signal", + "orca_min" and "orca_max" on the action name the input and its range. +""" +import math +from math import cos, pi, radians, sin + +import bpy +from mathutils import Matrix, Vector + +scene = bpy.context.scene + +R_EYE = 7.2 # camera to screen plane +W = 2.05 # half width of the screen plane +RES = (1920, 720) # display resolution +H = W * RES[1] / RES[0] # half height of the screen plane +RPM, SPEED = 5.4, 254 + +FONT_NUM = "/System/Library/Fonts/Supplemental/DIN Alternate Bold.ttf" +FONT_LBL = "/System/Library/Fonts/Supplemental/DIN Condensed Bold.ttf" + +# ---------------------------------------------------------------- reset +for name in ("IC_Graphics", "IC_Housing", "IC_Stage", "IC_Static", "IC_Dynamic", "IC_Camera", "InstrumentCluster"): + c = bpy.data.collections.get(name) + if c: + for o in list(c.objects): + bpy.data.objects.remove(o, do_unlink=True) + bpy.data.collections.remove(c) +for _ in range(3): + bpy.data.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) +for block in (bpy.data.materials, bpy.data.node_groups, bpy.data.texts): + for item in [i for i in block if i.name.startswith("IC_")]: + block.remove(item) +for action in list(bpy.data.actions): + bpy.data.actions.remove(action) + +top = bpy.data.collections.new("InstrumentCluster") +scene.collection.children.link(top) + + +def sub(name): + c = bpy.data.collections.new(name) + top.children.link(c) + return c + + +C_STATIC, C_DYNAMIC, C_CAMERA = sub("IC_Static"), sub("IC_Dynamic"), sub("IC_Camera") + + +def load_font(path): + try: + return bpy.data.fonts.load(path, check_existing=True) + except RuntimeError: + return None + + +F_NUM, F_LBL = load_font(FONT_NUM), load_font(FONT_LBL) + +# ---------------------------------------------------------------- shader node groups +WHITE, GREY, DIM = "#F2F4F7", "#8B9099", "#2B2E34" +RED, RED_HOT, RED_DEEP = "#FF2A1A", "#FF5A2E", "#7A0C06" +GREEN = "#35E06A" + + +def lin(hexcol): + """'#RRGGBB' (what the display should show) -> linear RGBA for node sockets.""" + def ch(i): + c = int(hexcol[i:i + 2], 16) / 255.0 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + return (ch(1), ch(3), ch(5), 1.0) + + +GLSL_HEADER = """// {name}: {doc} +// Mirrors the Blender node group "{name}"; uniform names are its input names. +// ORCA prepends the #version line and declares the FragColor output; colour +// uniforms are vec4 display (sRGB) values and only .rgb is used. +in vec2 v_texcoord0; +""" + + +class ShaderGroup: + """A node group that is a fragment shader: UV0 + inputs -> Color, Alpha.""" + + def __init__(self, name, doc, inputs, glsl): + self.tree = t = bpy.data.node_groups.new(name, 'ShaderNodeTree') + t["orca_doc"] = doc + for iname, default in inputs: + is_color = isinstance(default, str) + s = t.interface.new_socket(name=iname, in_out='INPUT', socket_type='NodeSocketColor' if is_color else 'NodeSocketFloat') + s.default_value = lin(default) if is_color else default + t.interface.new_socket(name="Color", in_out='OUTPUT', socket_type='NodeSocketColor') + t.interface.new_socket(name="Alpha", in_out='OUTPUT', socket_type='NodeSocketFloat') + self.inp, self.out = t.nodes.new('NodeGroupInput'), t.nodes.new('NodeGroupOutput') + tc, sep = t.nodes.new("ShaderNodeTexCoord"), t.nodes.new("ShaderNodeSeparateXYZ") + t.links.new(tc.outputs["UV"], sep.inputs[0]) + self.uv, self.u, self.v = tc.outputs["UV"], sep.outputs[0], sep.outputs[1] + uniforms = "".join("uniform {} {};\n".format("vec4" if isinstance(d, str) else "float", n) for n, d in inputs) + text = bpy.data.texts.new(name + ".frag") + text.write(GLSL_HEADER.format(name=name, doc=doc) + uniforms + glsl) + t["orca_fragment"] = text.name + + def i(self, name): + return self.inp.outputs[name] + + def math(self, op, a, b=None, clamp=False): + n = self.tree.nodes.new("ShaderNodeMath") + n.operation, n.use_clamp = op, clamp + for k, v in enumerate((a, b)): + if v is None: + continue + if isinstance(v, (int, float)): + n.inputs[k].default_value = v + else: + self.tree.links.new(v, n.inputs[k]) + return n.outputs[0] + + def mix(self, fac, a, b): + """Colour lerp, factor clamped. a/b: socket or '#hex'.""" + n = self.tree.nodes.new("ShaderNodeMix") + n.data_type, n.clamp_factor = 'RGBA', True + socks = {s.identifier: s for s in n.inputs} + for sock, v in ((socks["Factor_Float"], fac), (socks["A_Color"], a), (socks["B_Color"], b)): + if isinstance(v, str): + sock.default_value = lin(v) + elif isinstance(v, (int, float)): + sock.default_value = v + else: + self.tree.links.new(v, sock) + return next(s for s in n.outputs if s.identifier == "Result_Color") + + def done(self, color, alpha): + self.tree.links.new(color, self.out.inputs["Color"]) + self.tree.links.new(alpha, self.out.inputs["Alpha"]) + return self.tree + + +def sg_flat(): + g = ShaderGroup("IC_Flat", "solid colour", [("Tint", WHITE), ("Intensity", 1.0)], """ +void main() { + FragColor = vec4(Tint.rgb, Intensity); +} +""") + return g.done(g.i("Tint"), g.i("Intensity")) + + +def sg_gradient(): + g = ShaderGroup( + "IC_Gradient", "three-stop colour ramp along U, fading at both ends of U and softly across V", + [("Color0", WHITE), ("Color1", WHITE), ("Color2", WHITE), ("Pos0", 0.0), ("Pos1", 0.5), ("Pos2", 1.0), + ("Intensity", 1.0), ("FadeIn", 0.0), ("FadeOut", 0.0), ("Soft", 0.0)], """ +void main() { + float u = v_texcoord0.x, v = v_texcoord0.y; + vec3 c = mix(Color0.rgb, Color1.rgb, clamp((u - Pos0) / (Pos1 - Pos0), 0.0, 1.0)); + c = mix(c, Color2.rgb, clamp((u - Pos1) / (Pos2 - Pos1), 0.0, 1.0)); + float fade = clamp(u / max(FadeIn, 1e-4), 0.0, 1.0) * clamp((1.0 - u) / max(FadeOut, 1e-4), 0.0, 1.0); + float edge = 1.0 - abs(2.0 * v - 1.0); + float soft = mix(1.0, edge * edge, Soft); + FragColor = vec4(c, Intensity * fade * soft); +} +""") + t01 = g.math('DIVIDE', g.math('SUBTRACT', g.u, g.i("Pos0")), g.math('SUBTRACT', g.i("Pos1"), g.i("Pos0")), clamp=True) + t12 = g.math('DIVIDE', g.math('SUBTRACT', g.u, g.i("Pos1")), g.math('SUBTRACT', g.i("Pos2"), g.i("Pos1")), clamp=True) + col = g.mix(t12, g.mix(t01, g.i("Color0"), g.i("Color1")), g.i("Color2")) + fade_in = g.math('DIVIDE', g.u, g.math('MAXIMUM', g.i("FadeIn"), 1e-4), clamp=True) + fade_out = g.math('DIVIDE', g.math('SUBTRACT', 1.0, g.u), g.math('MAXIMUM', g.i("FadeOut"), 1e-4), clamp=True) + edge = g.math('SUBTRACT', 1.0, g.math('ABSOLUTE', g.math('SUBTRACT', g.math('MULTIPLY', g.v, 2.0), 1.0))) + edge2 = g.math('MULTIPLY', edge, edge) + # mix(1, edge2, Soft) = 1 - Soft * (1 - edge2) + soft = g.math('SUBTRACT', 1.0, g.math('MULTIPLY', g.i("Soft"), g.math('SUBTRACT', 1.0, edge2))) + alpha = g.math('MULTIPLY', g.math('MULTIPLY', g.i("Intensity"), g.math('MULTIPLY', fade_in, fade_out)), soft) + return g.done(col, alpha) + + +def sg_sweep(): + g = ShaderGroup( + "IC_Sweep", "gauge fill: U spans the whole scale, lit up to Level with a tail fading back over Tail", + [("Color0", RED_DEEP), ("Color1", RED_HOT), ("Level", 0.5), ("Tail", 0.2), ("Intensity", 0.8), ("InnerFade", 0.7)], """ +void main() { + float u = v_texcoord0.x, v = v_texcoord0.y; + float a = clamp((u - Level + Tail) / Tail, 0.0, 1.0); + float lit = u < Level ? 1.0 : 0.0; + float radial = 1.0 - InnerFade * (1.0 - v); + FragColor = vec4(mix(Color0.rgb, Color1.rgb, a), Intensity * a * a * lit * radial); +} +""") + a = g.math('DIVIDE', g.math('ADD', g.math('SUBTRACT', g.u, g.i("Level")), g.i("Tail")), g.i("Tail"), clamp=True) + lit = g.math('LESS_THAN', g.u, g.i("Level")) + radial = g.math('SUBTRACT', 1.0, g.math('MULTIPLY', g.i("InnerFade"), g.math('SUBTRACT', 1.0, g.v))) + alpha = g.math('MULTIPLY', g.math('MULTIPLY', g.i("Intensity"), g.math('MULTIPLY', a, a)), g.math('MULTIPLY', lit, radial)) + return g.done(g.mix(a, g.i("Color0"), g.i("Color1")), alpha) + + +def sg_segments(): + g = ShaderGroup( + "IC_Segments", "segmented bar: each segment has one U value, segments below Level are lit", + [("ColorOn", RED), ("ColorOff", DIM), ("Level", 0.5), ("Intensity", 1.0)], """ +void main() { + float lit = v_texcoord0.x < Level ? 1.0 : 0.0; + FragColor = vec4(mix(ColorOff.rgb, ColorOn.rgb, lit), Intensity); +} +""") + return g.done(g.mix(g.math('LESS_THAN', g.u, g.i("Level")), g.i("ColorOff"), g.i("ColorOn")), g.i("Intensity")) + + +def sg_spun_metal(): + g = ShaderGroup( + "IC_SpunMetal", "fake spun/brushed metal for rings and discs: conic highlights plus concentric grooves", + [("ColorDark", "#060708"), ("ColorLight", "#C8CDD6"), ("Lobes", 2.0), ("Phase", 0.125), ("Sharpness", 4.0), + ("Rings", 24.0), ("RingContrast", 0.4), ("EdgeDark", 0.5), ("Intensity", 1.0)], """ +void main() { + float u = v_texcoord0.x, v = v_texcoord0.y; + float conic = pow(abs(cos((u + Phase) * Lobes * 3.14159265)), Sharpness); + float ring = fract(sin(floor(v * Rings) * 12.9898) * 43758.5453); + float f = clamp(conic * (1.0 - RingContrast * (1.0 - ring)) + ring * RingContrast * 0.12, 0.0, 1.0); + float e = 2.0 * v - 1.0; + vec3 c = mix(ColorDark.rgb, ColorLight.rgb, f) * (1.0 - clamp(EdgeDark * e * e, 0.0, 1.0)); + FragColor = vec4(c, Intensity); +} +""") + conic = g.math('POWER', g.math('ABSOLUTE', g.math('COSINE', g.math( + 'MULTIPLY', g.math('ADD', g.u, g.i("Phase")), g.math('MULTIPLY', g.i("Lobes"), pi)))), g.i("Sharpness")) + ring = g.math('FRACT', g.math('MULTIPLY', g.math('SINE', g.math( + 'MULTIPLY', g.math('FLOOR', g.math('MULTIPLY', g.v, g.i("Rings"))), 12.9898)), 43758.5453)) + grain = g.math('SUBTRACT', 1.0, g.math('MULTIPLY', g.i("RingContrast"), g.math('SUBTRACT', 1.0, ring))) + f = g.math('ADD', g.math('MULTIPLY', conic, grain), + g.math('MULTIPLY', g.math('MULTIPLY', ring, g.i("RingContrast")), 0.12), clamp=True) + e = g.math('SUBTRACT', g.math('MULTIPLY', g.v, 2.0), 1.0) + edge = g.math('MULTIPLY', g.i("EdgeDark"), g.math('MULTIPLY', e, e), clamp=True) + return g.done(g.mix(edge, g.mix(f, g.i("ColorDark"), g.i("ColorLight")), "#000000"), g.i("Intensity")) + + +def sg_radial_glow(): + g = ShaderGroup( + "IC_RadialGlow", "soft round glow on a quad with UV 0..1", + [("Tint", RED), ("Intensity", 0.5), ("Power", 2.0)], """ +void main() { + float d = clamp(length(v_texcoord0 - vec2(0.5)) * 2.0, 0.0, 1.0); + FragColor = vec4(Tint.rgb, Intensity * pow(1.0 - d, Power)); +} +""") + off = g.tree.nodes.new("ShaderNodeVectorMath") + off.operation = 'SUBTRACT' + g.tree.links.new(g.uv, off.inputs[0]) + off.inputs[1].default_value = (0.5, 0.5, 0.0) + ln = g.tree.nodes.new("ShaderNodeVectorMath") + ln.operation = 'LENGTH' + g.tree.links.new(off.outputs[0], ln.inputs[0]) + d = g.math('MULTIPLY', ln.outputs["Value"], 2.0, clamp=True) + alpha = g.math('MULTIPLY', g.i("Intensity"), g.math('POWER', g.math('SUBTRACT', 1.0, d), g.i("Power"))) + return g.done(g.i("Tint"), alpha) + + +SG = {"flat": sg_flat(), "gradient": sg_gradient(), "sweep": sg_sweep(), "segments": sg_segments(), + "metal": sg_spun_metal(), "glow": sg_radial_glow()} + + +def material(name, shader, blend="opaque", **uniforms): + """One shader group + a blend mode. Uniform values live on the group node's inputs.""" + m = bpy.data.materials.new(name) + m.use_nodes = True + nt = m.node_tree + nt.nodes.clear() + out = nt.nodes.new("ShaderNodeOutputMaterial") + node = nt.nodes.new("ShaderNodeGroup") + node.node_tree = SG[shader] + node.name = node.label = "Shader" + for k, v in uniforms.items(): + node.inputs[k].default_value = lin(v) if isinstance(v, str) else v + em = nt.nodes.new("ShaderNodeEmission") + nt.links.new(node.outputs["Color"], em.inputs["Color"]) + if blend == "opaque": + sh = em.outputs[0] + else: + m.surface_render_method = 'BLENDED' + tr = nt.nodes.new("ShaderNodeBsdfTransparent") + if blend == "additive": + nt.links.new(node.outputs["Alpha"], em.inputs["Strength"]) + mixer = nt.nodes.new("ShaderNodeAddShader") + nt.links.new(em.outputs[0], mixer.inputs[0]) + nt.links.new(tr.outputs[0], mixer.inputs[1]) + else: + mixer = nt.nodes.new("ShaderNodeMixShader") + nt.links.new(node.outputs["Alpha"], mixer.inputs[0]) + nt.links.new(tr.outputs[0], mixer.inputs[1]) + nt.links.new(em.outputs[0], mixer.inputs[2]) + sh = mixer.outputs[0] + nt.links.new(sh, out.inputs[0]) + m["orca_shader"], m["orca_blend"] = SG[shader].name, blend + return m + + +def flat(name, color): + return material(name, "flat", Tint=color) + + +M = { + "white": flat("IC_White", WHITE), "grey": flat("IC_Grey", GREY), "dim": flat("IC_Dim", DIM), + "red": flat("IC_Red", RED), "red_dim": flat("IC_RedDim", "#5E130E"), "green": flat("IC_Green", GREEN), + "green_dim": flat("IC_GreenDim", "#10261A"), "tick_minor": flat("IC_TickMinor", "#9CA2AB"), + "card_edge": flat("IC_CardEdge", "#2A2D33"), "chip_edge": flat("IC_ChipEdge", "#565B65"), + "track": flat("IC_BarTrack", "#33363C"), + # Dial metalwork. U = angle, V = radius. + "face": material("IC_DialFace", "metal", ColorDark="#040506", ColorLight="#3A3E46", Lobes=2.0, Phase=0.125, + Sharpness=3.0, Rings=150.0, RingContrast=0.28, EdgeDark=0.0), + "bezel": material("IC_DialBezel", "metal", ColorDark="#08090B", ColorLight="#C2C8D2", Lobes=2.0, Phase=0.125, + Sharpness=9.0, Rings=16.0, RingContrast=0.45, EdgeDark=0.75), + "hub_groove": material("IC_HubGroove", "metal", ColorDark="#020203", ColorLight="#3C4048", Lobes=2.0, Phase=0.125, + Sharpness=4.0, Rings=8.0, RingContrast=0.5, EdgeDark=0.85), + "hub_ring": material("IC_HubRing", "metal", ColorDark="#34373D", ColorLight="#F4F7FB", Lobes=2.0, Phase=0.125, + Sharpness=2.0, Rings=1.0, RingContrast=0.0, EdgeDark=0.3), + # U = radius: hub face, slightly lifted in the middle. + "hub": material("IC_Hub", "gradient", Color0="#101216", Color1="#08090B", Color2="#020203"), + # Rev scale: U runs 0..1 from 0 to 8 thousand. + "rim": material("IC_RevRim", "gradient", Color0="#D8DDE5", Color1="#FF8A3A", Color2=RED, Pos0=0.50, Pos1=0.62, Pos2=0.74), + "rim_glow": material("IC_RevRimGlow", "gradient", "additive", Color0="#3A3E46", Color1=RED_HOT, Color2=RED, + Pos0=0.45, Pos1=0.62, Pos2=0.74, Intensity=0.55, FadeIn=0.04, FadeOut=0.03, Soft=1.0), + "bezel_glow": material("IC_BezelGlow", "gradient", "additive", Color0="#1B1D22", Color1=RED, Color2=RED, + Pos0=0.40, Pos1=0.66, Pos2=1.0, Intensity=0.55, FadeIn=0.06, FadeOut=0.06, Soft=1.0), + "sweep": material("IC_RevSweep", "sweep", "alpha", Color0=RED_DEEP, Color1=RED_HOT, Level=RPM / 8.0, Tail=0.17, + Intensity=0.8, InnerFade=0.75), + "needle": material("IC_Needle", "gradient", Color0=RED, Color1=RED_HOT, Color2="#FFD2B0"), + "needle_glow": material("IC_NeedleGlow", "gradient", "additive", Color0=RED, Color1=RED_HOT, Color2=RED_HOT, + Intensity=0.85, FadeIn=0.3, Soft=1.0), + # Side panels. + "chevron": material("IC_Chevron", "gradient", "alpha", Color0=RED, Color1="#FF6A48", Color2=RED, FadeIn=0.22, FadeOut=0.22), + "chevron_glow": material("IC_ChevronGlow", "gradient", "additive", Color0=RED, Color1=RED_HOT, Color2=RED, + Intensity=0.5, FadeIn=0.22, FadeOut=0.22, Soft=1.0), + "plate": material("IC_PanelPlate", "gradient", Color0="#14161A", Color1="#0A0B0D", Color2="#030304"), + "card": material("IC_Card", "gradient", Color0="#07080A", Color1="#0D0E11", Color2="#16181C"), + "range": material("IC_RangeFill", "gradient", Color0=RED, Color1=RED, Color2="#FFD8CC", Pos1=0.75), + # Ambient light behind everything. + "ambient_red": material("IC_AmbientRed", "glow", "additive", Tint=RED, Intensity=0.30, Power=2.0), + "ambient_dial": material("IC_AmbientDial", "glow", "additive", Tint=RED, Intensity=0.16, Power=2.0), + "ambient_cool": material("IC_AmbientCool", "glow", "additive", Tint="#AEB8C8", Intensity=0.10, Power=2.0), +} + +# ---------------------------------------------------------------- mesh helpers + + +def mesh_obj(name, geo, mat, parent, loc=(0, 0, 0), coll=None, binding=None): + verts, faces, uvs = geo + me = bpy.data.meshes.new(name) + me.from_pydata([tuple(v) for v in verts], [], faces) + if uvs: + layer = me.uv_layers.new(name="UVMap") + for loop in me.loops: + layer.data[loop.index].uv = uvs[loop.vertex_index] + me.materials.append(mat) + ob = bpy.data.objects.new(name, me) + (coll or (C_DYNAMIC if binding else C_STATIC)).objects.link(ob) + ob.parent = parent + ob.location = loc + if binding: + ob["orca_binding"] = binding + return ob + + +def arc(r0, r1, a0, a1, n=None, radial_u=False): + """Ring sector. U runs along the sweep a0 -> a1 and V across the band, or swapped.""" + n = n or max(4, int(abs(a1 - a0) / radians(2.5))) + verts, uvs = [], [] + for i in range(n + 1): + f = i / n + a = a0 + (a1 - a0) * f + verts += [(r0 * cos(a), r0 * sin(a), 0), (r1 * cos(a), r1 * sin(a), 0)] + uvs += [(0, f), (1, f)] if radial_u else [(f, 0), (f, 1)] + return verts, [(2 * i, 2 * i + 1, 2 * i + 3, 2 * i + 2) for i in range(n)], uvs + + +def stroke(pts, w, closed=False): + """Mitred polyline of width w. U is the normalised distance along it, V runs across.""" + pts = [Vector(p) for p in pts] + n = len(pts) + + def normal(a, b): + d = (b - a).normalized() + return Vector((-d.y, d.x)) + + dist = [0.0] + for i in range(1, n): + dist.append(dist[-1] + (pts[i] - pts[i - 1]).length) + total = dist[-1] + ((pts[0] - pts[-1]).length if closed else 0.0) + verts, uvs = [], [] + for i, p in enumerate(pts): + n1 = normal(pts[i - 1], p) if (i > 0 or closed) else None + n2 = normal(p, pts[(i + 1) % n]) if (i < n - 1 or closed) else None + if n1 is None or n2 is None: + mitre = n1 or n2 + else: + mitre = (n1 + n2).normalized() + mitre = mitre / max(mitre.dot(n1), 0.35) + verts += [(p + mitre * w / 2).to_3d(), (p - mitre * w / 2).to_3d()] + uvs += [(dist[i] / total, 0), (dist[i] / total, 1)] + faces = [(2 * i, 2 * i + 1, 2 * i + 3, 2 * i + 2) for i in range(n - 1)] + if closed: + faces.append((2 * n - 2, 2 * n - 1, 1, 0)) + return verts, faces, uvs + + +def quads(qs, us=None): + """Separate quads; with `us`, every vertex of quad i gets U = us[i].""" + verts, faces, uvs = [], [], [] + for k, q in enumerate(qs): + b = len(verts) + verts += [(p[0], p[1], 0) for p in q] + faces.append((b, b + 1, b + 2, b + 3)) + uvs += [(us[k], 0.5)] * 4 if us else [(0, 0), (1, 0), (1, 1), (0, 1)] + return verts, faces, uvs + + +def fan(pts, u_of): + """Convex outline filled from its centre; U from u_of(x, y).""" + cx, cy = sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts) + allp = [(cx, cy)] + list(pts) + faces = [(0, 1 + i, 1 + (i + 1) % len(pts)) for i in range(len(pts))] + return [(x, y, 0) for x, y in allp], faces, [(u_of(x, y), 0.5) for x, y in allp] + + +def poly(pts): + return [(p[0], p[1], 0) for p in pts], [tuple(range(len(pts)))], None + + +def slanted(x, y, w, h, slant): + return [(x - w / 2 - slant, y - h / 2), (x + w / 2 - slant, y - h / 2), + (x + w / 2 + slant, y + h / 2), (x - w / 2 + slant, y + h / 2)] + + +def rounded_rect(x0, y0, x1, y1, r, n=6): + pts = [] + for cx, cy, a0 in ((x1 - r, y1 - r, 0), (x0 + r, y1 - r, 90), (x0 + r, y0 + r, 180), (x1 - r, y0 + r, 270)): + pts += [(cx + r * cos(radians(a0 + 90 * i / n)), cy + r * sin(radians(a0 + 90 * i / n))) for i in range(n + 1)] + return pts + + +TEXTS = [] + + +def text(name, body, size, x, y, z, mat, parent, font=None, shear=0.0, ax='CENTER', binding=None): + cu = bpy.data.curves.new(name, 'FONT') + cu.body, cu.size, cu.shear = body, size, shear + cu.align_x, cu.align_y = ax, 'CENTER' + if font: + cu.font = font + cu.materials.append(mat) + ob = bpy.data.objects.new(name, cu) + (C_DYNAMIC if binding else C_STATIC).objects.link(ob) + ob.parent = parent + ob.location = (x, y, z) + if binding: + ob["orca_binding"] = binding + ob["orca_text"], ob["orca_text_size"], ob["orca_text_shear"] = body, size, shear + ob["orca_text_align"], ob["orca_text_font"] = ax, font.name if font else "" + TEXTS.append(ob) + return ob + + +# ---------------------------------------------------------------- layout on the camera-centred cylinder + + +def bend(s, t, d): + """Flat screen coords (s across, t up, d behind the screen plane) -> world.""" + phi, r = s / R_EYE, R_EYE + d + return Vector((r * sin(phi), -R_EYE + r * cos(phi), t)) + + +def facing(s, t, d): + """Transform whose local XY plane faces the camera from screen position (s, t, d); local +Z is towards the eye.""" + return Matrix.Translation(bend(s, t, d)) @ Matrix.Rotation(-s / R_EYE, 4, 'Z') @ Matrix.Rotation(pi / 2, 4, 'X') + + +root = bpy.data.objects.new("IC_Root", None) +root.empty_display_size = 0.3 +C_STATIC.objects.link(root) + + +def pod(name, s, t, d): + e = bpy.data.objects.new(name, None) + e.empty_display_size = 0.1 + C_STATIC.objects.link(e) + e.parent = root + e.matrix_basis = facing(s, t, d) + return e + + +def gfx(name, geo, mat, parent, z, binding=None): + return mesh_obj(name, geo, mat, parent, (0, 0, z), binding=binding) + + +def card(name, panel, x0, y0, x1, y1, z, r=0.035, fill="card", edge="card_edge"): + pts = rounded_rect(x0, y0, x1, y1, r) + gfx(name, fan(pts, lambda x, y: (y - y0) / (y1 - y0)), M[fill], panel, z) + gfx(name + "_Edge", stroke(pts, 0.004, closed=True), M[edge], panel, z + 0.005) + + +# ---------------------------------------------------------------- ambient glows behind the panels +GLOW_QUAD = ([(-1, -1, 0), (1, -1, 0), (1, 1, 0), (-1, 1, 0)], [(0, 1, 2, 3)], [(0, 0), (1, 0), (1, 1), (0, 1)]) +for name, mat, s, t, d, size in (("Ambient_DialRed", "ambient_dial", 0.55, -0.05, 0.9, 1.35), + ("Ambient_DialCool", "ambient_cool", -0.55, 0.30, 0.9, 1.0), + ("Ambient_LeftRed", "ambient_red", -2.15, 0.0, 0.9, 1.0), + ("Ambient_RightRed", "ambient_red", 2.15, 0.0, 0.9, 1.0)): + ob = mesh_obj(name, GLOW_QUAD, M[mat], root) + ob.matrix_basis = facing(s, t, d) @ Matrix.Diagonal((size, size, 1, 1)) + +# ---------------------------------------------------------------- centre: rev counter +dial = pod("IC_Panel_Rev", 0.0, 0.0, 0.34) +TURN = 2 * pi + + +def A(v): + """Dial angle for a rev value 0..8 (270 degree sweep, open at the bottom).""" + return radians(225.0 - v / 8.0 * 270.0) + + +def ticks(values, r0, r1, w): + qs = [] + for v in values: + a = A(v) + d, n = Vector((cos(a), sin(a))), Vector((-sin(a), cos(a))) * (w / 2) + qs.append([d * r0 - n, d * r1 - n, d * r1 + n, d * r0 + n]) + return quads(qs) + + +gfx("Rev_BezelGlow", arc(0.62, 0.94, A(0), A(8)), M["bezel_glow"], dial, 0.0) +gfx("Rev_Face", arc(0.0, 0.657, 0, TURN, n=128), M["face"], dial, 0.01) +gfx("Rev_Bezel", arc(0.656, 0.78, 0, TURN, n=128), M["bezel"], dial, 0.02) +gfx("Rev_Sweep", arc(0.345, 0.636, A(0), A(8)), M["sweep"], dial, 0.04, binding="rpm: Level = rpm / 8000") +gfx("Rev_RedZone", arc(0.60, 0.632, A(6.5), A(8)), M["red_dim"], dial, 0.05) +gfx("Rev_RimGlow", arc(0.585, 0.715, A(0), A(8)), M["rim_glow"], dial, 0.06) +gfx("Rev_Rim", arc(0.640, 0.656, A(0), A(8)), M["rim"], dial, 0.08) +gfx("Rev_TicksMajor", ticks(range(0, 7), 0.555, 0.632, 0.014), M["white"], dial, 0.08) +gfx("Rev_TicksMajorRed", ticks((7, 8), 0.555, 0.632, 0.014), M["red"], dial, 0.08) +gfx("Rev_TicksMinor", ticks([i + 0.5 for i in range(8)], 0.588, 0.632, 0.008), M["tick_minor"], dial, 0.08) +gfx("Rev_TicksFine", ticks([i / 10 for i in range(81) if i % 5], 0.61, 0.632, 0.004), M["grey"], dial, 0.08) +for v in range(9): + a = A(v) + text("Rev_Num{:d}".format(v), str(v), 0.105, 0.475 * cos(a), 0.475 * sin(a), 0.12, + M["red"] if v >= 7 else M["white"], dial, F_NUM) + +# Needle points along its local +X with the pivot on the dial centre: rotate about local Z. +needle = gfx("Rev_Needle", quads([[(0.345, -0.012), (0.662, -0.0035), (0.662, 0.0035), (0.345, 0.012)]]), M["needle"], dial, 0.16, + binding="rpm: rotation Z = 225deg - rpm / 8000 * 270deg") +needle.rotation_euler = (0, 0, A(RPM)) +mesh_obj("Rev_NeedleGlow", quads([[(0.345, -0.045), (0.68, -0.03), (0.68, 0.03), (0.345, 0.045)]]), M["needle_glow"], + needle, (0, 0, -0.01), coll=C_DYNAMIC) + +gfx("Rev_HubGroove", arc(0.300, 0.348, 0, TURN, n=96), M["hub_groove"], dial, 0.20) +gfx("Rev_HubRing", arc(0.290, 0.302, 0, TURN, n=96), M["hub_ring"], dial, 0.21) +gfx("Rev_Hub", arc(0.0, 0.291, 0, TURN, n=72, radial_u=True), M["hub"], dial, 0.20) +text("Rev_Scale", "1/min x 1000", 0.036, 0.0, 0.187, 0.24, M["grey"], dial, F_LBL) +text("Rev_Speed", str(SPEED), 0.205, 0.0, 0.052, 0.24, M["white"], dial, F_NUM, binding="speed_kmh") +text("Rev_SpeedUnit", "km/h", 0.042, 0.0, -0.078, 0.24, M["grey"], dial, F_LBL) +text("Gear_Mode", "M", 0.078, -0.072, -0.185, 0.24, M["grey"], dial, F_NUM, binding="gear_mode") +text("Gear_Number", "5", 0.13, 0.04, -0.18, 0.24, M["red"], dial, F_NUM, binding="gear") + +ARROW = [(-0.04, 0), (0, 0.034), (0, 0.013), (0.04, 0.013), (0.04, -0.013), (0, -0.013), (0, -0.034)] +for name, x, sign in (("Turn_Left", -0.83, 1), ("Turn_Right", 0.83, -1)): + ob = mesh_obj(name, poly([(sign * px, py) for px, py in ARROW]), M["green_dim"], dial, (x, 0.69, 0.10), binding="turn_signal") + ob["orca_material_on"], ob["orca_material_off"] = M["green"].name, M["green_dim"].name + +# ---------------------------------------------------------------- side panels +PANEL_S = 1.44 + + +def panel_frame(panel, m): + """Dark plate with a red chevron on the outer edge; m = +1 for the left panel, -1 for the right.""" + name = panel.name.replace("IC_Panel_", "") + plate = [(0.52 * m, 0.66), (-0.36 * m, 0.66), (-0.58 * m, 0.0), (-0.36 * m, -0.66), (0.52 * m, -0.66)] + chevron = [(0.14 * m, 0.70), (-0.38 * m, 0.70), (-0.62 * m, 0.0), (-0.38 * m, -0.70), (0.14 * m, -0.70)] + gfx(name + "_Plate", fan(plate, lambda x, y: (x * m + 0.58) / 1.10), M["plate"], panel, 0.0) + gfx(name + "_ChevronGlow", stroke(chevron, 0.11), M["chevron_glow"], panel, 0.02) + gfx(name + "_Chevron", stroke(chevron, 0.012), M["chevron"], panel, 0.03) + + +def chaikin(pts, rounds=3): + pts = [Vector(p) for p in pts] + for _ in range(rounds): + nxt = [] + for i, p in enumerate(pts): + q = pts[(i + 1) % len(pts)] + nxt += [p * 0.75 + q * 0.25, p * 0.25 + q * 0.75] + pts = nxt + return pts + + +# Left: lap timing and a (made up) circuit map. +left = pod("IC_Panel_Lap", -PANEL_S, 0.0, 0.26) +panel_frame(left, 1) +text("Lap_Count", "LAP 2 / 12", 0.062, -0.28, 0.555, 0.08, M["white"], left, F_LBL, ax='LEFT', binding="lap_count") +card("Lap_MapCard", left, -0.30, 0.13, 0.40, 0.49, 0.04) +circuit = chaikin([(-0.28, 0.02), (-0.22, 0.20), (-0.08, 0.27), (0.02, 0.18), (0.10, 0.24), (0.24, 0.26), (0.30, 0.14), + (0.20, 0.04), (0.26, -0.05), (0.12, -0.07), (0.00, 0.02), (-0.12, -0.06), (-0.24, -0.06)]) +circuit = [Vector((0.05 + (p.x - 0.01) * 0.86, 0.31 + (p.y - 0.10) * 0.80)) for p in circuit] +done = circuit[:int(len(circuit) * 0.42)] +gfx("Lap_Circuit", stroke(circuit, 0.008, closed=True), M["grey"], left, 0.08) +gfx("Lap_CircuitDone", stroke(done, 0.011), M["white"], left, 0.10, binding="lap_progress") +mesh_obj("Lap_Car", arc(0.0, 0.018, 0, TURN, n=24), M["red"], left, (done[-1].x, done[-1].y, 0.12), + binding="lap_progress: position along Lap_Circuit") +text("Lap_Time", "1:24.317", 0.125, -0.28, 0.005, 0.10, M["white"], left, F_NUM, ax='LEFT', binding="lap_time") +text("Lap_Delta", "-0.438", 0.058, -0.28, -0.105, 0.10, M["green"], left, F_NUM, ax='LEFT', binding="lap_delta") +card("Lap_TimesCard", left, -0.30, -0.52, 0.40, -0.20, 0.04) +for label, value, y in (("BEST", "1:42.108", -0.295), ("LAST", "1:42.546", -0.425)): + text("Lap_{:s}Label".format(label.title()), label, 0.05, -0.25, y, 0.08, M["grey"], left, F_LBL, ax='LEFT') + text("Lap_{:s}".format(label.title()), value, 0.062, 0.35, y, 0.10, M["white"], left, F_NUM, ax='RIGHT', + binding="lap_" + label.lower()) + +# Right: temperatures, boost, range and drive mode. +right = pod("IC_Panel_Vitals", PANEL_S, 0.0, 0.26) +panel_frame(right, -1) +SEGS = 10 +card("Vitals_Selection", right, -0.50, 0.405, -0.30, 0.515, 0.04, r=0.02, edge="chip_edge") +for row, (label, value, n_lit) in enumerate((("OIL", "112°C", 6), ("WATER", "96°C", 6), ("BOOST", "1.6 bar", 5))): + y = 0.46 - row * 0.19 + key = label.lower() + text("Vitals_{:s}_Label".format(label), label, 0.05, -0.40 if row == 0 else -0.48, y, 0.08, M["white"], right, F_LBL, + ax='CENTER' if row == 0 else 'LEFT') + text("Vitals_{:s}_Caption".format(label), label, 0.028, -0.245, y + 0.055, 0.08, M["grey"], right, F_LBL, ax='LEFT') + text("Vitals_{:s}_Value".format(label), value, 0.06, 0.39, y, 0.10, M["white"], right, F_NUM, ax='RIGHT', binding=key) + bar = material("IC_Bar_" + label.title(), "segments", ColorOn=RED, ColorOff="#30333A", Level=n_lit / SEGS) + gfx("Vitals_{:s}_Bar".format(label), + quads([slanted(-0.225 + i * 0.034, y - 0.008, 0.026, 0.046, 0.007) for i in range(SEGS)], + us=[(i + 0.5) / SEGS for i in range(SEGS)]), + bar, right, 0.08, binding=key + ": Level = fraction of full scale") +text("Vitals_RangeLabel", "RANGE", 0.05, -0.05, -0.10, 0.08, M["grey"], right, F_LBL) +text("Vitals_Range", "412 km", 0.125, -0.05, -0.215, 0.10, M["white"], right, F_NUM, binding="range_km") +gfx("Vitals_RangeTrack", quads([[(-0.42, -0.338), (0.32, -0.338), (0.32, -0.322), (-0.42, -0.322)]]), M["track"], right, 0.08) +# Range level: quad pivoted on its left end, scale X is the fill. +fuel = mesh_obj("Vitals_RangeLevel", quads([[(0, -0.008), (0.74, -0.008), (0.74, 0.008), (0, 0.008)]]), M["range"], right, + (-0.42, -0.33, 0.10), binding="fuel: scale X = level 0..1") +fuel.scale = (0.55, 1, 1) +card("Vitals_ModeCard", right, -0.42, -0.565, 0.38, -0.435, 0.04, r=0.02) +text("Vitals_ModeA", "TRACK", 0.062, -0.21, -0.50, 0.10, M["red"], right, F_NUM, shear=0.18, binding="drive_mode: active entry is red") +text("Vitals_ModeB", "SPORT+", 0.062, 0.14, -0.50, 0.10, M["grey"], right, F_NUM, shear=0.18, binding="drive_mode") + +# ---------------------------------------------------------------- bake text to mesh +bpy.context.view_layer.update() +depsgraph = bpy.context.evaluated_depsgraph_get() +for ob in TEXTS: + name, curve = ob.name, ob.data + me = bpy.data.meshes.new_from_object(ob.evaluated_get(depsgraph)) + ob.name = name + "_font" + me.name = name + baked = bpy.data.objects.new(name, me) + for c in ob.users_collection: + c.objects.link(baked) + baked.parent = ob.parent + baked.matrix_basis = ob.matrix_basis.copy() + for k in ob.keys(): + baked[k] = ob[k] + bpy.data.objects.remove(ob, do_unlink=True) + bpy.data.curves.remove(curve) + +# ---------------------------------------------------------------- gauge animations +# Every gauge has one "fill-up" action running from empty (frame 0) to full +# (frame GAUGE_FRAMES) with linear keys. The runtime never plays these: it sets +# the clip time from an input value, so time / duration is the gauge reading. +# An action animates whatever makes up that gauge: object transforms and shader +# inputs (uniforms) alike, each in its own slot. +GAUGE_FRAMES = 100 +scene.render.fps = 100 # frame / fps = 0..1 s, i.e. clip time is the normalised reading +scene.frame_start, scene.frame_end = 0, GAUGE_FRAMES + + +def gauge(name, signal, lo, hi): + """New fill-up action. `signal` names the input and lo..hi its range over the clip.""" + action = bpy.data.actions.new(name) + action["orca_signal"], action["orca_min"], action["orca_max"] = signal, lo, hi + action.use_fake_user = True + return action + + +def key(action, owner, target, prop, v0, v1, index=-1): + """Key target.prop from v0 at frame 0 to v1 at the last frame. `owner` is the ID that holds the animation.""" + ad = owner.animation_data or owner.animation_data_create() + ad.action = action + for frame, value in ((0, v0), (GAUGE_FRAMES, v1)): + if index >= 0: + getattr(target, prop)[index] = value + else: + setattr(target, prop, value) + target.keyframe_insert(prop, index=index, frame=frame) + + +def uniform(mat, name): + return mat.node_tree.nodes["Shader"].inputs[name] + + +rev = gauge("Gauge_Rev", "rpm", 0.0, 8000.0) +key(rev, needle, needle, "rotation_euler", A(0), A(8), index=2) +key(rev, M["sweep"].node_tree, uniform(M["sweep"], "Level"), "default_value", 0.0, 1.0) +for label, signal, lo, hi in (("Oil", "oil_temp_c", 50.0, 150.0), ("Water", "water_temp_c", 50.0, 130.0), + ("Boost", "boost_bar", 0.0, 2.5)): + mat = bpy.data.materials["IC_Bar_" + label] + key(gauge("Gauge_" + label, signal, lo, hi), mat.node_tree, uniform(mat, "Level"), "default_value", 0.0, 1.0) +key(gauge("Gauge_Range", "fuel_level", 0.0, 1.0), fuel, fuel, "scale", 0.0, 1.0, index=0) + +for action in bpy.data.actions: + for layer in action.layers: + for strip in layer.strips: + for bag in strip.channelbags: + for fc in bag.fcurves: + for kp in fc.keyframe_points: + kp.interpolation = 'LINEAR' +scene.frame_set(round(RPM / 8.0 * GAUGE_FRAMES)) + +# ---------------------------------------------------------------- camera = the display +cam_data = bpy.data.cameras.new("IC_DisplayCamera") +cam_data.sensor_fit, cam_data.sensor_width = 'HORIZONTAL', 36.0 +cam_data.lens = cam_data.sensor_width / 2 * R_EYE / W +cam_data.clip_start, cam_data.clip_end = 1.0, 20.0 +cam = bpy.data.objects.new("IC_DisplayCamera", cam_data) +C_CAMERA.objects.link(cam) +cam.location = (0, -R_EYE, 0) +cam.rotation_euler = (pi / 2, 0, 0) +fov_h = 2 * math.degrees(math.atan(W / R_EYE)) +fov_v = 2 * math.degrees(math.atan(H / R_EYE)) +cam["orca_fov_horizontal"], cam["orca_fov_vertical"] = fov_h, fov_v +scene.camera = cam + +# ---------------------------------------------------------------- render settings: what you see is what the display shows +world = scene.world or bpy.data.worlds.new("IC_World") +scene.world = world +world.use_nodes = True +wn = world.node_tree +wn.nodes.clear() +bg, wout = wn.nodes.new("ShaderNodeBackground"), wn.nodes.new("ShaderNodeOutputWorld") +bg.inputs["Color"].default_value = (0, 0, 0, 1) +bg.inputs["Strength"].default_value = 0.0 +wn.links.new(bg.outputs[0], wout.inputs[0]) + +scene.render.engine = 'BLENDER_EEVEE' +scene.render.resolution_x, scene.render.resolution_y, scene.render.resolution_percentage = RES[0], RES[1], 100 +scene.render.use_compositing = False +scene.view_settings.view_transform = 'Standard' +scene.view_settings.look = 'None' + +for area in bpy.context.screen.areas: + if area.type == 'VIEW_3D': + area.spaces.active.region_3d.view_perspective = 'CAMERA' + area.spaces.active.shading.type = 'MATERIAL' + +meshes = [o for o in top.all_objects if o.type == 'MESH'] +mats = [m for m in bpy.data.materials if m.name.startswith("IC_")] +result = { + "objects": len(top.all_objects), + "meshes": len(meshes), + "triangles": sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in meshes), + "materials": len(mats), + "materials_by_shader": {g.name: sorted(m.name for m in mats if m["orca_shader"] == g.name) for g in SG.values()}, + "shader_inputs": {g.name: [s.name for s in g.interface.items_tree if s.in_out == 'INPUT'] for g in SG.values()}, + "fragment_texts": sorted(t.name for t in bpy.data.texts if t.name.startswith("IC_")), + "dynamic": len(C_DYNAMIC.objects), + "leftover_text": len([o for o in top.all_objects if o.type == 'FONT']), + "actions": {a.name: [(s.identifier, len(list(a.layers[0].strips[0].channelbag(s).fcurves))) for s in a.slots] + for a in bpy.data.actions}, +} diff --git a/apps/cluster-demo/blender/cluster.blend b/apps/cluster-demo/blender/cluster.blend new file mode 100644 index 00000000..5d8e41b9 Binary files /dev/null and b/apps/cluster-demo/blender/cluster.blend differ diff --git a/apps/cluster-demo/package.lua b/apps/cluster-demo/package.lua new file mode 100644 index 00000000..d6f8cc4a --- /dev/null +++ b/apps/cluster-demo/package.lua @@ -0,0 +1,42 @@ +Name = "ClusterDemo" +StartupScreen = "ClusterDemo/Screens/Cluster" +WindowWidth = 1920 +WindowHeight = 720 +ScreenLibrary = { IsExternal = true } +SystemMessages = { + { Message="KeyDown", Key="q", Command="return" }, + { Message="WindowClosed", Command="return" }, + { Message="RequestReload", Command="window:refresh()" }, +} +EnginePlugins = { + { Name="orca.UIKit" }, + { Name="orca.SceneKit" }, +} +-- Shader uniforms. tools/blender-export.py reports any that are missing here. +PropertyTypes = { + { Name = "Color0", Category = "Uniform", DataType = "Color" }, + { Name = "Color1", Category = "Uniform", DataType = "Color" }, + { Name = "Color2", Category = "Uniform", DataType = "Color" }, + { Name = "ColorDark", Category = "Uniform", DataType = "Color" }, + { Name = "ColorLight", Category = "Uniform", DataType = "Color" }, + { Name = "ColorOff", Category = "Uniform", DataType = "Color" }, + { Name = "ColorOn", Category = "Uniform", DataType = "Color" }, + { Name = "EdgeDark", Category = "Uniform", DataType = "Float" }, + { Name = "FadeIn", Category = "Uniform", DataType = "Float" }, + { Name = "FadeOut", Category = "Uniform", DataType = "Float" }, + { Name = "InnerFade", Category = "Uniform", DataType = "Float" }, + { Name = "Intensity", Category = "Uniform", DataType = "Float" }, + { Name = "Level", Category = "Uniform", DataType = "Float" }, + { Name = "Lobes", Category = "Uniform", DataType = "Float" }, + { Name = "Phase", Category = "Uniform", DataType = "Float" }, + { Name = "Pos0", Category = "Uniform", DataType = "Float" }, + { Name = "Pos1", Category = "Uniform", DataType = "Float" }, + { Name = "Pos2", Category = "Uniform", DataType = "Float" }, + { Name = "Power", Category = "Uniform", DataType = "Float" }, + { Name = "RingContrast", Category = "Uniform", DataType = "Float" }, + { Name = "Rings", Category = "Uniform", DataType = "Float" }, + { Name = "Sharpness", Category = "Uniform", DataType = "Float" }, + { Name = "Soft", Category = "Uniform", DataType = "Float" }, + { Name = "Tail", Category = "Uniform", DataType = "Float" }, + { Name = "Tint", Category = "Uniform", DataType = "Color" }, +} diff --git a/docs/animations.md b/docs/animations.md index fa94890e..374d4153 100644 --- a/docs/animations.md +++ b/docs/animations.md @@ -39,8 +39,8 @@ An `AnimationClip` is an object loaded from an XML file (just like an `Image` or - - + + @@ -76,50 +76,56 @@ Each `AnimationCurve` is a child object of an `AnimationClip`. It targets exactl | Property | Type | Description | |---|---|---| | `Property` | string | Short property name to animate, e.g. `"Opacity"`, `"Position"` | -| `Path` | string | Relative path from the player's host object to the target object. Empty means the host itself | +| `Path` | string | Relative path from the object that carries the `AnimationPlayer` to the target object (`..` is supported). Empty means that object itself | | `Keyframes` | `Keyframe[]` | The keyframe array (see format below) | | `NumKeyframes` | int | Number of keyframes in the array (auto-generated from `array="true"`) | ### `Keyframe` struct -Each keyframe is a `Keyframe` struct serialised as space-separated numbers. The `value`, `inSlope`, `outSlope`, `inWeight`, and `outWeight` fields are each a `Vector4D` (`x y z w`). +Each keyframe is a `Keyframe` struct serialised as space-separated numbers. The `Value`, `InSlope`, `OutSlope`, `InWeight`, and `OutWeight` fields are each a `Vector4D` (`x y z w`). | Field | Type | Description | |---|---|---| -| `time` | float | Time position in seconds | -| `value` | vec4 | Animated value (only the components used by the target property are read) | -| `inSlope` | vec4 | Incoming tangent slope for cubic bezier interpolation | -| `outSlope` | vec4 | Outgoing tangent slope | -| `inWeight` | vec4 | Incoming tangent weight | -| `outWeight` | vec4 | Outgoing tangent weight | -| `tangentMode` | int | `0` = cubic bezier (default); any other value = linear | -| `weightedMode` | int | Whether tangent weights are active for this keyframe | +| `Time` | float | Time position in seconds | +| `Value` | vec4 | Animated value (only the components used by the target property are read) | +| `InSlope` | vec4 | Incoming tangent slope for cubic bezier interpolation | +| `OutSlope` | vec4 | Outgoing tangent slope | +| `InWeight` | vec4 | Incoming tangent weight | +| `OutWeight` | vec4 | Outgoing tangent weight | +| `TangentMode` | int | `0` = cubic bezier (default); any other value = linear | +| `WeightedMode` | int | Whether tangent weights are active for this keyframe | -**Compact XML syntax** — keyframe values are written as a space-separated sequence in the order: `time value.x value.y value.z value.w`: +> **Note — field names are case-sensitive in XML.** Write the attributes exactly as listed above. Lowercase names are rejected by the loader, e.g. `Could not set field 'time' on struct 'Keyframe'`. + +**Compact XML syntax** — each keyframe is a `` element with `Time` and `Value` attributes; `Value` is a space-separated sequence in the order `x y z w`: ```xml - - + + + + + + ``` -For scalar properties (e.g. `Opacity`, `FontSize`) only `value.x` is used. For two-component properties (e.g. `Position`) `value.x` and `value.y` are used, and so on. +For scalar properties (e.g. `Opacity`, `FontSize`) only `Value.x` is used. For two-component properties (e.g. `Position`) `Value.x` and `Value.y` are used, and so on. #### Tangent mode -| `tangentMode` | Interpolation | +| `TangentMode` | Interpolation | |---|---| | `0` | Cubic bezier (Unity-compatible, default) | | `1` | Linear | -When `tangentMode == 0` the cubic bezier control points are derived from the slopes and weights: +When `TangentMode == 0` the cubic bezier control points are derived from the slopes and weights: ``` -h0 = v0 + outSlope * outWeight * dt -h1 = v1 - inSlope * inWeight * dt +h0 = v0 + OutSlope * OutWeight * dt +h1 = v1 - InSlope * InWeight * dt ``` -where `dt = kf[j].time - kf[i].time`. +where `dt = kf[j].Time - kf[i].Time`. #### Bool properties @@ -144,6 +150,8 @@ local player = obj:addComponent("AnimationPlayer") obj.Clip = require("MyApp/Animations/FadeOut") ``` +> **Note — XML placement.** An `` XML element is an object of its own: it is a child of the element it is written in. `AnimationCurve.Path` is resolved relative to that player object, and `..` is supported, so `Path=".."` targets the element the player is written in. + ### Properties | Property | Type | Default | Description | @@ -160,6 +168,8 @@ obj.Clip = require("MyApp/Animations/FadeOut") | `DurationScale` | float | `0` | Additional speed multiplier applied on top of `Speed` (0 = no scaling) | | `RepeatCount` | int | `0` | How many times to repeat (`-1` = infinite, `0` = use clip's own mode) | +> **Note — scrubbing.** Setting `CurrentTime` while the player is not playing applies the clip at that time. This lets a clip be bound to an input value (for example a slider or a data value) instead of the clock. + ### `PlaybackMode` enum | Value | Behaviour | @@ -267,9 +277,9 @@ This internally creates a `PropertyAnimation` component component that removes i - - - + + + @@ -277,8 +287,8 @@ This internally creates a `PropertyAnimation` component component that removes i - - + + @@ -316,5 +326,5 @@ end) - **`StopTime = 0` means no automatic stop.** The player will advance `CurrentTime` indefinitely. Always set `StopTime` to a positive value when you want the clip to end. - **`DurationScale = 0` means no extra scaling.** The effective speed is `Speed * (DurationScale > 0 ? DurationScale : 1)`. Setting `DurationScale = 0` does *not* freeze playback. - **`AnimationClip.Mode` overrides player-level looping.** If you set `Mode="Loop"` on the clip, the player's `Looping` property and `PlaybackMode` are ignored. -- **Multi-component properties** (e.g. `Position` is a `vec2`). Write all relevant components in the keyframe's `value` field: `value="100 200 0 0"` for `x=100, y=200`. -- **Path targeting.** If `AnimationCurve.Path` is non-empty, the engine calls `OBJ_FindByPath` relative to the player's host object each frame. Keep paths short; use the empty string to target the host object itself. +- **Multi-component properties** (e.g. `Position` is a `vec2`). Write all relevant components in the keyframe's `Value` field: `Value="100 200 0 0"` for `x=100, y=200`. +- **Path targeting.** If `AnimationCurve.Path` is non-empty, the engine calls `OBJ_FindByPath` relative to the object that carries the `AnimationPlayer` each frame (for an `` XML element that is the player object itself, so use `..` to reach the enclosing element). Keep paths short; an empty path targets the object that carries the player. diff --git a/include/module.h b/include/module.h index 321bd96c..b5c42f2a 100644 --- a/include/module.h +++ b/include/module.h @@ -211,6 +211,7 @@ ORCA_API BOOL UI_GetObjectProperty(struct Object *, lpcString_t, LPPROPDEF); ORCA_API BOOL UI_GetProperty(DWORD, LPPROPDEF); ORCA_API void UI_RefreshProperty(DWORD); ORCA_API TIME UI_GetTime(void); +ORCA_API void core_AdvanceFrame(void); ORCA_API lpcString_t UI_LocalizeString(lpcString_t, LOCALE_TYPE); ORCA_API HRESULT UI_Save(lpcString_t); ORCA_API BOOL UI_GetObjectItem(struct Object *, LPOBJDEF); diff --git a/plugins/SceneKit/Model3D.c b/plugins/SceneKit/Model3D.c index 8af4ab77..ff37bf06 100644 --- a/plugins/SceneKit/Model3D.c +++ b/plugins/SceneKit/Model3D.c @@ -8,7 +8,7 @@ _CollectUniforms(struct Object *hObject, struct Material* mat, struct ViewEntity* ent, struct uniform* u, - uint32_t blendMode) + int blendMode) { ent->material.blendMode = (enum blend_mode)kBlendModeAlphaAutomatic; ent->material.uniforms = u; @@ -23,7 +23,9 @@ _CollectUniforms(struct Object *hObject, OBJ_GetUniforms(CMP_GetObject(mat), u + ent->material.numUniforms); } - if (blendMode != kBlendModeMixedAlpha) { + // The node's own Material.BlendMode only overrides when it names a real mode: + // unset reads back as -1 or AlphaAutomatic, which used to wipe out the material's. + if (blendMode > kBlendModeAlphaAutomatic && blendMode != kBlendModeMixedAlpha) { ent->material.blendMode = (enum blend_mode)blendMode; } } diff --git a/plugins/SceneKit/PlaneMeshNode.c b/plugins/SceneKit/PlaneMeshNode.c index 75ab6c92..0f0c8e8b 100644 --- a/plugins/SceneKit/PlaneMeshNode.c +++ b/plugins/SceneKit/PlaneMeshNode.c @@ -9,7 +9,7 @@ _CollectUniforms(struct Object *, struct Material*, struct ViewEntity*, struct uniform*, - uint32_t); + int); HANDLER(PlaneMeshNode, Node3D, Render) { diff --git a/plugins/SceneKit/PrefabView3D.c b/plugins/SceneKit/PrefabView3D.c index f6b76113..66f2a256 100644 --- a/plugins/SceneKit/PrefabView3D.c +++ b/plugins/SceneKit/PrefabView3D.c @@ -25,9 +25,12 @@ HANDLER(PrefabView3D, Object, PropertyChanged) HANDLER(PrefabView3D, Node, LoadView) { if (pPrefabView3D->Prefab && *pPrefabView3D->Prefab) { + uint32_t const prefab = fnv1a32(pPrefabView3D->Prefab); + if (pPrefabView3D->_loaded == prefab) return TRUE; OBJ_Clear(hObject); struct Object *loaded = FS_LoadObject(pPrefabView3D->Prefab); if (loaded) { + pPrefabView3D->_loaded = prefab; OBJ_AddChild(hObject, loaded); } else { Con_Error("Failed to load prefab '%s'", pPrefabView3D->Prefab); diff --git a/plugins/SceneKit/RenderPass.c b/plugins/SceneKit/RenderPass.c index fe7f65ce..cecc99b7 100644 --- a/plugins/SceneKit/RenderPass.c +++ b/plugins/SceneKit/RenderPass.c @@ -94,7 +94,7 @@ MAT4_Stereoperspective(float eyeOffset, float far) { struct mat4 out = MAT4_Identity(); - float radians = fov * 3.14159f / 360.0f; + float radians = fov * (float)M_PI / 360.0f; float f = 1.0f / tanf(radians); float width = f / aspectRatio; float height = f; @@ -124,11 +124,11 @@ static struct mat4 _GetProjectionMatrix(struct view_camera* c, struct ViewDef* vd) { float aspect = vd->viewSize.x / vd->viewSize.y; - float fov = (c->verticalFOV ? c->fov : (c->fov / aspect)) * 1.05f; + float fov = c->verticalFOV ? c->fov + : atanf(tanf(c->fov * (float)M_PI / 360.0f) / aspect) * 360.0f / (float)M_PI; float eye = 0.175f * vd->stereoSeparation; float dist = 50; struct mat4 out = MAT4_Stereoperspective(eye, dist, fov, aspect, c->zNear, c->zFar); - // out = MAT4_Perspective(fov * 1.05f, aspect, data->zNear, data->zFar); if (c->zPositive) { out.v[10] = -out.v[10]; diff --git a/plugins/SceneKit/SceneKit.cgen b/plugins/SceneKit/SceneKit.cgen index 38aa03ed..8f1b1c9c 100644 --- a/plugins/SceneKit/SceneKit.cgen +++ b/plugins/SceneKit/SceneKit.cgen @@ -131,11 +131,11 @@

3D camera for scene viewing and projection configuration
Defines the viewpoint and projection parameters for rendering 3D scenes. Supports both perspective and orthographic projections with configurable field of view, clipping planes, and stereo rendering.
- Field of view angle in degrees (60-90 typical for perspective cameras) + Field of view angle in degrees (60-90 typical for perspective cameras) Whether field of view is measured horizontally or vertically Perspective or orthographic projection mode - Near clipping plane distance (objects closer than this are not rendered) - Far clipping plane distance (objects farther than this are not rendered) + Near clipping plane distance (objects closer than this are not rendered) + Far clipping plane distance (objects farther than this are not rendered) Distance between left and right eyes for stereoscopic rendering Focus distance for depth of field and stereo convergence Height of viewing volume for orthographic projection @@ -182,6 +182,9 @@ Path to prefab asset file to instantiate + + Hash of the prefab path currently instantiated + Base class for render pass components in the rendering pipeline @@ -246,6 +249,9 @@ + + Text colour (white when unset) + diff --git a/plugins/SceneKit/TextBlock3D.c b/plugins/SceneKit/TextBlock3D.c index 97014f3c..eb5c6aab 100644 --- a/plugins/SceneKit/TextBlock3D.c +++ b/plugins/SceneKit/TextBlock3D.c @@ -19,7 +19,7 @@ HANDLER(TextBlock3D, Node3D, Render) .bbox = BOX3_FromRect(((struct rect){-w/2,-h/2,w,h})), .material = (struct ViewMaterial) { .opacity = GetNode3D(hObject)->_opacity, - .color = {1,1,1,1}, + .color = pTextBlock3D->Color, // all zero (unset) draws white .texture = (struct Texture*)_SendMessage(hObject, TextBlockConcept, GetTexture), .blendMode = BLEND_MODE_PREMULTIPLIED_ALPHA, }, diff --git a/plugins/SceneKit/Viewport3D.c b/plugins/SceneKit/Viewport3D.c index 0d645067..860ac6a3 100644 --- a/plugins/SceneKit/Viewport3D.c +++ b/plugins/SceneKit/Viewport3D.c @@ -87,7 +87,9 @@ HANDLER(Viewport3D, Node2D, ForegroundContent) lpcString_t camera = Viewport3D_camera(hObject); bool_t camalias = camera && *camera == '#'; struct rect viewrect = _Node2D_GetRect(GetNode2D(hObject)); - struct rect scrnrect = _Node2D_GetRect(GetNode2D(screen)); + // Screen is a host object without a Node2D; fall back to the viewport's own rect. + struct Node2D *screen2D = screen ? GetNode2D(screen) : NULL; + struct rect scrnrect = screen2D ? _Node2D_GetRect(screen2D) : viewrect; // struct ViewEntity entity = { // .type = ET_VIEWPORT, diff --git a/plugins/UIKit/Screen.c b/plugins/UIKit/Screen.c index fcdfe154..fa55bbc6 100644 --- a/plugins/UIKit/Screen.c +++ b/plugins/UIKit/Screen.c @@ -261,6 +261,8 @@ draw_screen(struct Object* hObject, // Screen_Paint HANDLER(Screen, Window, Paint) { + // Every painted frame moves the engine clock; time-based animation reads it. + core_AdvanceFrame(); PROP_RunAllPrograms(hObject); R_BeginFrame(pScreen->ClearColor); diff --git a/premake5.lua b/premake5.lua index ad1a6a7e..09a2918e 100644 --- a/premake5.lua +++ b/premake5.lua @@ -376,9 +376,11 @@ project "orca-fbx" targetdir "build/bin" language "C++" files { - "source/tools/fbx-import.cpp" + "tools/fbx-import.cpp" } includedirs { + ".", + "generated", "/Applications/Autodesk/FBX SDK/2020.2/include" } libdirs { @@ -387,7 +389,11 @@ project "orca-fbx" links { "fbxsdk" } - + -- The static FBX SDK depends on these system libraries. + filter "system:macosx" + links { "CoreFoundation.framework", "iconv", "xml2", "z" } + filter {} + -- Helper targets newaction { trigger = "run", diff --git a/source/core/components/AnimationPlayer.c b/source/core/components/AnimationPlayer.c index 78b7ed13..e7e85e86 100644 --- a/source/core/components/AnimationPlayer.c +++ b/source/core/components/AnimationPlayer.c @@ -66,6 +66,43 @@ static float keyframe_evaluate(struct Keyframe const *kf, int n, float time, int return vec4_get(&kf[0].value, xyzw); } +// Apply every AnimationCurve of the clip to its target at the given time. +static void apply_clip(struct Object *hObject, struct AnimationClip *clip, float time) { + struct Object *clipObj = CMP_GetObject(clip); + if (!clipObj) return; + FOR_EACH_OBJECT(child, clipObj) { + struct AnimationCurve *curve = GetAnimationCurve(child); + if (!curve) continue; + if (!curve->Keyframes || curve->NumKeyframes == 0) continue; + if (!curve->Property || !curve->Property[0]) continue; + + struct Object *target = (curve->Path && curve->Path[0]) + ? OBJ_FindByPath(hObject, curve->Path) + : hObject; + if (!target) continue; + + struct Property *property = OBJ_FindShortProperty(target, fnv1a32(curve->Property)); + if (!property) continue; + + float value[4]; + for (int i = 0; i < 4; i++) { + value[i] = keyframe_evaluate(curve->Keyframes, curve->NumKeyframes, time, i); + } + + if (PROP_GetType(property) == kDataTypeBool) { + float prev = keyframe_evaluate( + curve->Keyframes, curve->NumKeyframes, time - BOOL_EDGE_DELTA, 0); + // Treat a rising edge (prev < current) differently from steady/falling. + if (prev < value[0]) { + value[0] = (value[0] >= 1.0f) ? 1.0f : 0.0f; + } else { + value[0] = (value[0] > 0.0f) ? 1.0f : 0.0f; + } + } + PROP_SetValue(property, value); + } +} + // AnimationPlayer_Start HANDLER(AnimationPlayer, Object, Start) { struct AnimationClip *clip = pAnimationPlayer->Clip; @@ -99,7 +136,8 @@ HANDLER(AnimationPlayer, Object, Animate) { // Initialise timing on the first frame after playback starts if (pAnimationPlayer->_prevRealtime == 0) { pAnimationPlayer->_prevRealtime = core.realtime; - return FALSE; + OBJ_RequestAnimate(hObject); // keep the loop alive: this frame only primes the clock + return TRUE; } float dt = (float)(core.realtime - pAnimationPlayer->_prevRealtime) / 1000.0f; @@ -107,44 +145,7 @@ HANDLER(AnimationPlayer, Object, Animate) { float scale = pAnimationPlayer->DurationScale > 0.0f ? pAnimationPlayer->DurationScale : 1.0f; pAnimationPlayer->CurrentTime += dt * pAnimationPlayer->Speed * scale; - // Evaluate all AnimationCurve child objects of the clip - struct Object *clipObj = CMP_GetObject(clip); - if (clipObj) { - FOR_EACH_OBJECT(child, clipObj) { - struct AnimationCurve *curve = GetAnimationCurve(child); - if (!curve) continue; - if (!curve->Keyframes || curve->NumKeyframes == 0) continue; - if (!curve->Property || !curve->Property[0]) continue; - - struct Object *target = (curve->Path && curve->Path[0]) - ? OBJ_FindByPath(hObject, curve->Path) - : hObject; - if (!target) continue; - - struct Property *property = OBJ_FindShortProperty(target, fnv1a32(curve->Property)); - if (!property) continue; - - float value[4]; - for (int i = 0; i < 4; i++) { - value[i] = keyframe_evaluate( - curve->Keyframes, curve->NumKeyframes, - pAnimationPlayer->CurrentTime, i); - } - - if (PROP_GetType(property) == kDataTypeBool) { - float prev = keyframe_evaluate( - curve->Keyframes, curve->NumKeyframes, - pAnimationPlayer->CurrentTime - BOOL_EDGE_DELTA, 0); - // Treat a rising edge (prev < current) differently from steady/falling. - if (prev < value[0]) { - value[0] = (value[0] >= 1.0f) ? 1.0f : 0.0f; - } else { - value[0] = (value[0] > 0.0f) ? 1.0f : 0.0f; - } - } - PROP_SetValue(property, value); - } - } + apply_clip(hObject, clip, pAnimationPlayer->CurrentTime); // Check for end of clip if (clip->StopTime > 0.0f) { @@ -192,6 +193,18 @@ HANDLER(AnimationPlayer, Object, Animate) { if (pAnimationPlayer->Playing) { OBJ_RequestAnimate(hObject); } + // Handled: the app loop answers with a paint, which is what advances core.realtime. + return TRUE; +} + +// AnimationPlayer_PropertyChanged +// Scrubbing: writing CurrentTime while the player is not playing poses the +// targets at that time, so a clip can be driven by a value instead of the clock. +HANDLER(AnimationPlayer, Object, PropertyChanged) { + if (!pPropertyChanged || !pPropertyChanged->Property) return FALSE; + if (PROP_GetLongIdentifier(pPropertyChanged->Property) != ID_AnimationPlayer_CurrentTime) return FALSE; + if (pAnimationPlayer->Playing || !pAnimationPlayer->Clip) return FALSE; + apply_clip(hObject, pAnimationPlayer->Clip, pAnimationPlayer->CurrentTime); return FALSE; } diff --git a/source/core/core.cgen b/source/core/core.cgen index 1c6948d5..9e1a553a 100644 --- a/source/core/core.cgen +++ b/source/core/core.cgen @@ -689,6 +689,7 @@ Do not query layout geometry or child tree in Create — hierarchy may be incomp + @@ -700,7 +701,7 @@ Do not query layout geometry or child tree in Create — hierarchy may be incomp Whether the animation is currently advancing each frame If true, the animation restarts from StartTime when it reaches StopTime Playback speed multiplier (1.0 = normal speed) - Current playback position within the animation in seconds + Current playback position within the animation in seconds. Setting it while the player is not playing applies the clip at that time (scrubbing) Enables or disables automatic playback on initialization. When true, playback is relative to the current state rather than starting from an absolute base state. Restores the original property values after the animation ends. diff --git a/source/core/core_main.c b/source/core/core_main.c index 825a9c47..09e85df3 100644 --- a/source/core/core_main.c +++ b/source/core/core_main.c @@ -540,7 +540,7 @@ static int f_parse_property(lua_State* L) { } } -void core_AdvanceFrame(void) { +ORCA_API void core_AdvanceFrame(void) { core.realtime = axGetMilliseconds(); core.frame++; } diff --git a/source/core/object/object_core.c b/source/core/object/object_core.c index 2c371898..2facf8eb 100644 --- a/source/core/object/object_core.c +++ b/source/core/object/object_core.c @@ -192,13 +192,13 @@ OBJ_AddComponentByName(lua_State* L, struct Object *pobj, lpcString_t className) static uint8_t find_tag(lpcString_t tag) { uint8_t i; - for (i = 0; i < MAX_TAGS; i++) { + for (i = 0; i < MAX_TAGS && *core.tags[i]; i++) { if (!strcmp(core.tags[i], tag)) { return i; } } if (i < MAX_TAGS) { - strcpy(core.tags[i], tag); + strncpy(core.tags[i], tag, sizeof(core.tags[i]) - 1); return i; } return 0xFF; @@ -210,7 +210,7 @@ ORCA_API objectTags_t GetTagsFromString(lpcString_t value) { for (lpcString_t tag = strtok(tmp, ","); tag; tag = strtok(NULL, ",")) { uint8_t tag_id = find_tag(tag); if (tag_id != 0xFF) { - tags |= (1 << tag_id); + tags |= ((objectTags_t)1 << tag_id); } } free(tmp); diff --git a/source/core/object/object_properties.c b/source/core/object/object_properties.c index e0a05769..9e4e3a15 100644 --- a/source/core/object/object_properties.c +++ b/source/core/object/object_properties.c @@ -58,7 +58,10 @@ OBJ_GetUniforms(struct Object *object, struct uniform* pUniforms) pUniforms->Type = UT_FLOAT_VEC4; PROP_CopyValue(property, pUniforms->Value); break; + default: + continue; } + break; case kDataTypeInt: case kDataTypeBool: case kDataTypeEnum: diff --git a/source/filesystem/fs_xml.c b/source/filesystem/fs_xml.c index 8693d485..db7c2fc4 100644 --- a/source/filesystem/fs_xml.c +++ b/source/filesystem/fs_xml.c @@ -609,7 +609,7 @@ node(struct _xmlNode* x, const struct ds_schema *schema, const char *entity_name } FOR_EACH_LIST(xmlNode, t, x->children) { - if (t->type == XML_TEXT_NODE && xmlStrlen(t->content) > 0) { + if ((t->type == XML_TEXT_NODE || t->type == XML_CDATA_SECTION_NODE) && xmlStrlen(t->content) > 0) { lpcString_t text = (lpcString_t)t->content; OBJ_SetTextContent(o, text); return o; diff --git a/source/renderer/r_shader.c b/source/renderer/r_shader.c index f75ffd19..37e9ad97 100644 --- a/source/renderer/r_shader.c +++ b/source/renderer/r_shader.c @@ -797,6 +797,20 @@ HANDLER(Shader, Object, Start) { for (struct Property *p=OBJ_GetProperties(hObject);p;p=PROP_GetNext(p)){ struct uniform_def ud = {0}; lpcString_t local = PROP_GetName(p); + switch (PROP_GetLongIdentifier(p)) { + case ID_Shader_BlendMode: + desc.UniversalTarget.HasBlendMode = TRUE; + desc.UniversalTarget.BlendMode = (enum blend_mode)pShader->BlendMode; + continue; + case ID_Shader_DepthTestFunction: + desc.UniversalTarget.HasDepthTestFunction = TRUE; + desc.UniversalTarget.DepthTestFunction = (enum compare_func)pShader->DepthTestFunction; + continue; + case ID_Shader_DepthWriteEnabled: + desc.UniversalTarget.HasDepthWriteEnabled = TRUE; + desc.UniversalTarget.DepthWriteEnabled = pShader->DepthWriteEnabled; + continue; + } if (strrchr(local, '.')) { local = strrchr(local, '.') + 1; } diff --git a/source/renderer/texture/r_texture.c b/source/renderer/texture/r_texture.c index 0e351d9d..ee5d7ed2 100644 --- a/source/renderer/texture/r_texture.c +++ b/source/renderer/texture/r_texture.c @@ -217,7 +217,9 @@ Texture_Create(PCREATEIMGSTRUCT _in, struct Texture** pImage) struct Texture* texture = ZeroAlloc(sizeof(struct Texture)); if (!texture) return E_OUTOFMEMORY; - if (_in->ImageData) { + // tr.buffer is 0 until the renderer has a GL context (headless tests never get one). + bool_t const has_gl = tr.buffer != 0; + if (_in->ImageData && has_gl) { R_Call(glGenTextures, 1, &texture->texnum); Texture_Reallocate(texture, _in); } @@ -225,7 +227,7 @@ Texture_Create(PCREATEIMGSTRUCT _in, struct Texture** pImage) texture->MagnificationFilter = _in->MagFilter; texture->WrapMode = _in->WrapMode; - R_ApplyImageParms(texture, GL_TEXTURE_2D, FALSE); + if (has_gl) R_ApplyImageParms(texture, GL_TEXTURE_2D, FALSE); *pImage = texture; diff --git a/tests/test_animations.lua b/tests/test_animations.lua index b15b3645..2aa795dd 100644 --- a/tests/test_animations.lua +++ b/tests/test_animations.lua @@ -315,6 +315,37 @@ local function test_animation_player_reverse_start() print("PASS: test_animation_player_reverse_start") end +-- --------------------------------------------------------------------------- +-- Test 15: Scrubbing: setting CurrentTime on a stopped player applies the clip +-- --------------------------------------------------------------------------- +local function test_animation_player_scrub() + local screen = ui.Screen { Width = 200, Height = 200, ResizeMode = "NoResize" } + local node = screen + ui.Node2D {} + + node:addComponentByName("AnimationPlayer") + + local clip = core.AnimationClip() + clip.StartTime = 0.0 + clip.StopTime = 1.0 + local curve = clip + core.AnimationCurve() + curve.Property = "Opacity" + -- Two keyframes with zeroed values: enough to tell "curve applied" (0) from + -- "untouched" (1) without depending on how keyframe values are filled in. + curve.Keyframes = { { Time = 0.0 }, { Time = 1.0 } } + node.Clip = clip + + node.Opacity = 1.0 + test.expect(not node.Playing, "player is stopped") + test.expect_near(node.Opacity, 1.0, 0.001, "assigning a clip does not touch the target") + + node.CurrentTime = 0.5 + test.expect_near(node.Opacity, 0.0, 0.001, "setting CurrentTime applies the clip") + test.expect(not node.Playing, "scrubbing does not start playback") + + node:removeFromParent() + print("PASS: test_animation_player_scrub") +end + -- --------------------------------------------------------------------------- -- Run all tests -- --------------------------------------------------------------------------- @@ -332,5 +363,6 @@ test_animation_player_and_dotween_coexist() test_animation_player_duration_scale() test_animation_player_playback_mode() test_animation_player_reverse_start() +test_animation_player_scrub() print("All animation tests passed.") diff --git a/tests/test_filesystem.c b/tests/test_filesystem.c index ab29f696..03764234 100644 --- a/tests/test_filesystem.c +++ b/tests/test_filesystem.c @@ -1,15 +1,10 @@ /* - * test_filesystem.c — C unit tests for FS_LoadObject's query-arg parsing. + * test_filesystem.c — C unit tests for the XML loader helpers in + * source/filesystem/fs_xml_inline.h. * - * Tests cover the _ParseLoaderArgs contract: - * - No query string: argc stays at argc_start, argv[argc_start] = NULL - * - Single bare flag: argv[1] = "mask", argc = 2 - * - Key=value pair: argv[1] = "width=48", argc = 2 - * - Multiple args: splits on '&', all land in argv correctly - * - argv[argc] is always NULL (mirrors main() convention) - * - Truncation: arg longer than 64 chars is silently truncated to 64 chars - * - Too many args: extras beyond argc_max are silently dropped - * - Leading '?' in query string is stripped + * The query-string loader-argument parser (_ParseLoaderArgs) that this file + * used to cover was removed together with the .svg file loader; its tests + * went with it. * * Compiled via the `test-filesystem` Makefile target (depends on `buildlib`). */ @@ -32,30 +27,6 @@ extern int luaopen_orca_core(lua_State *L); extern int luaopen_orca_geometry(lua_State *L); extern int luaopen_orca_renderer(lua_State *L); -/* Mirror the constants from filesystem.c so we can declare matching arrays. - * If these diverge from the source, the _Static_assert below will catch it. */ -#define FS_LOADER_MAX_ARGS 16 -#define FS_LOADER_ARG_LEN 65 /* 64 visible chars + NUL */ - -/* _ParseLoaderArgs is non-static (exported from liborca.so for testability). */ -extern int _ParseLoaderArgs(const char *query_string, - const char *argv[], - int argc_start, - int argc_max, - char arg_buf[][FS_LOADER_ARG_LEN]); - -/* ------------------------------------------------------------------ */ -/* Helper: allocate argv + arg_buf, preset argv[0] = path. */ -/* ------------------------------------------------------------------ */ - -static int -call_parse(const char *path, const char *query, - const char **argv, char arg_buf[][FS_LOADER_ARG_LEN]) -{ - argv[0] = path; - return _ParseLoaderArgs(query, argv, 1, FS_LOADER_MAX_ARGS, arg_buf); -} - static void ensure_class_registry(void) { @@ -80,139 +51,6 @@ ensure_class_registry(void) /* Tests */ /* ------------------------------------------------------------------ */ -static void test_no_query(void) -{ - RUN_TEST("no_query_string", { - const char *argv[FS_LOADER_MAX_ARGS + 1]; - char arg_buf[FS_LOADER_MAX_ARGS][FS_LOADER_ARG_LEN]; - int argc = call_parse("img.png", NULL, argv, arg_buf); - EXPECT(argc == 1); - EXPECT(argv[0] != NULL); - EXPECT_STR_EQ(argv[0], "img.png"); - EXPECT(argv[1] == NULL); /* sentinel */ - }); -} - -static void test_empty_query(void) -{ - RUN_TEST("empty_query_string", { - const char *argv[FS_LOADER_MAX_ARGS + 1]; - char arg_buf[FS_LOADER_MAX_ARGS][FS_LOADER_ARG_LEN]; - int argc = call_parse("img.png", "", argv, arg_buf); - EXPECT(argc == 1); - EXPECT(argv[1] == NULL); - }); -} - -static void test_leading_question_mark(void) -{ - RUN_TEST("leading_question_mark_stripped", { - const char *argv[FS_LOADER_MAX_ARGS + 1]; - char arg_buf[FS_LOADER_MAX_ARGS][FS_LOADER_ARG_LEN]; - int argc = call_parse("img.png", "?mask", argv, arg_buf); - EXPECT(argc == 2); - EXPECT_STR_EQ(argv[1], "mask"); - EXPECT(argv[2] == NULL); - }); -} - -static void test_single_flag(void) -{ - RUN_TEST("single_bare_flag", { - const char *argv[FS_LOADER_MAX_ARGS + 1]; - char arg_buf[FS_LOADER_MAX_ARGS][FS_LOADER_ARG_LEN]; - int argc = call_parse("img.png", "?mask", argv, arg_buf); - EXPECT(argc == 2); - EXPECT_STR_EQ(argv[0], "img.png"); - EXPECT_STR_EQ(argv[1], "mask"); - EXPECT(argv[2] == NULL); - }); -} - -static void test_key_value(void) -{ - RUN_TEST("key_value_pair", { - const char *argv[FS_LOADER_MAX_ARGS + 1]; - char arg_buf[FS_LOADER_MAX_ARGS][FS_LOADER_ARG_LEN]; - int argc = call_parse("img.png", "?width=48", argv, arg_buf); - EXPECT(argc == 2); - EXPECT_STR_EQ(argv[1], "width=48"); - EXPECT(argv[2] == NULL); - }); -} - -static void test_multiple_args(void) -{ - RUN_TEST("multiple_args_split_on_ampersand", { - const char *argv[FS_LOADER_MAX_ARGS + 1]; - char arg_buf[FS_LOADER_MAX_ARGS][FS_LOADER_ARG_LEN]; - int argc = call_parse("img.png", "?width=48&type=mask&scale=2", argv, arg_buf); - EXPECT(argc == 4); - EXPECT_STR_EQ(argv[0], "img.png"); - EXPECT_STR_EQ(argv[1], "width=48"); - EXPECT_STR_EQ(argv[2], "type=mask"); - EXPECT_STR_EQ(argv[3], "scale=2"); - EXPECT(argv[4] == NULL); - }); -} - -static void test_sentinel_always_null(void) -{ - /* Verify argv[argc] == NULL for a non-trivial case. */ - RUN_TEST("argv_argc_always_null", { - const char *argv[FS_LOADER_MAX_ARGS + 1]; - char arg_buf[FS_LOADER_MAX_ARGS][FS_LOADER_ARG_LEN]; - int argc = call_parse("a.png", "?x=1&y=2", argv, arg_buf); - EXPECT(argc == 3); - EXPECT(argv[argc] == NULL); - }); -} - -static void test_truncation(void) -{ - /* An arg that is 65+ chars must be silently truncated to 64 visible chars. */ - RUN_TEST("long_arg_truncated_to_64_chars", { - /* 70 'a' characters */ - const char *argv[FS_LOADER_MAX_ARGS + 1]; - char arg_buf[FS_LOADER_MAX_ARGS][FS_LOADER_ARG_LEN]; - char long_arg[80]; - memset(long_arg, 'a', 70); - long_arg[70] = '\0'; - char query[80 + 1]; - snprintf(query, sizeof(query), "?%s", long_arg); - int argc = call_parse("img.png", query, argv, arg_buf); - EXPECT(argc == 2); - EXPECT(argv[1] != NULL); - EXPECT(strlen(argv[1]) == 64); /* truncated to 64 visible chars */ - EXPECT(argv[2] == NULL); - }); -} - -static void test_too_many_args(void) -{ - /* Build a query with MAX_LOADER_ARGS + 2 entries: only first MAX-1 should survive. */ - RUN_TEST("extra_args_beyond_max_dropped", { - const char *argv[FS_LOADER_MAX_ARGS + 1]; - char arg_buf[FS_LOADER_MAX_ARGS][FS_LOADER_ARG_LEN]; - /* Construct "?a0&a1&...&a17" — 18 args, more than the 15 slots available */ - char query[512]; - int pos = 0; - query[pos++] = '?'; - for (int i = 0; i < FS_LOADER_MAX_ARGS + 2; i++) { - if (i > 0) query[pos++] = '&'; - int rem = (int)sizeof(query) - pos; - if (rem <= 0) break; - pos += snprintf(query + pos, (size_t)rem, "a%d", i); - } - query[pos] = '\0'; - int argc = call_parse("img.png", query, argv, arg_buf); - /* argc_max = FS_LOADER_MAX_ARGS means at most FS_LOADER_MAX_ARGS-1 query args - * (since argv[0] = path uses slot 0 and argc_start = 1), giving argc == FS_LOADER_MAX_ARGS. */ - EXPECT(argc == FS_LOADER_MAX_ARGS); - EXPECT(argv[argc] == NULL); - }); -} - static void test_inline_xml_expansion(void) { RUN_TEST("inline_xml_expansion_supports_comma_and_nested_braces", { @@ -231,15 +69,6 @@ static void test_inline_xml_expansion(void) int main(void) { - test_no_query(); - test_empty_query(); - test_leading_question_mark(); - test_single_flag(); - test_key_value(); - test_multiple_args(); - test_sentinel_always_null(); - test_truncation(); - test_too_many_args(); test_inline_xml_expansion(); printf("\n%d test(s) run, %d failure(s)\n", s_tests_run, s_tests_failed); diff --git a/tests/test_layout.lua b/tests/test_layout.lua index 0f47a1ca..7dbfa55b 100644 --- a/tests/test_layout.lua +++ b/tests/test_layout.lua @@ -938,8 +938,12 @@ local function test_css_popup_padding_insets_stretched_panel() test.expect_near(card.BorderWidthLeft, 1, 0.001, ".popup > .panel CSS border should set panel border") test.expect_near(card.BorderRadius.TopLeftRadius, 16, 0.001, ".popup > .panel CSS border-radius should set panel corner radius") - test.expect_near(card.ActualWidth, root.Width - 32 - 2, 11, - "stretched panel should be inset by popup padding and its border") + -- align-items: center sizes the panel to its content (CSS semantics), so it + -- must fit inside the popup padding and wrap its body plus its own padding. + test.expect(card.ActualWidth <= root.Width - 32, + "centered panel should fit inside the popup padding") + test.expect(card.ActualWidth >= body.ActualWidth + 48, + "panel should wrap its body plus its own padding") test.expect(body.ActualWidth <= root.Width - 32 - 2 - 48, "body text should be measured inside popup and panel padding") @@ -1293,7 +1297,9 @@ local function test_example_application_xml() local city_image = appdata_xml:find("orca-tab-city", 1, true) or xml:find("orca-tab-city", 1, true) local lights_image = appdata_xml:find("orca-tab-lights", 1, true) or xml:find("orca-tab-lights", 1, true) local icon_count = count_occurrences(xml, "Example/Icons/") + count_occurrences(appdata_xml or "", "Example/Icons/") - local icon_mask_count = count_occurrences(xml, "&type=mask") + count_occurrences(appdata_xml or "", "&type=mask") + -- SVG icons are VectorViews now; the "&type=mask" loader query no longer exists. + local legacy_mask_count = count_occurrences(xml, "&type=mask") + count_occurrences(appdata_xml or "", "&type=mask") + local vector_icon_count = count_occurrences(xml, "= 10, "Example Application.xml should reference at least 10 SVG icons") - test.expect(icon_mask_count >= 10, "Example Application.xml should use masked SVG icons") + test.expect(legacy_mask_count == 0, "Example Application.xml should not use the removed mask query for icons") + test.expect(vector_icon_count >= 1, "Example Application.xml should render SVG icons with VectorView") test.expect(broken_icon_palette == nil, "Example Application.xml should not use the custom icon-* palette") test.expect(symbol_icon == nil, "Example Application.xml should not use text glyphs for card icons") test.expect(placeholder_refs == nil, "Example Application.xml should not reference placeholder icon filenames") @@ -1417,7 +1424,7 @@ local function test_example_application_xml() test.expect(xml_model_nested_padding ~= nil, "XmlModel nested rows should have Indent=48 in data") test.expect(xml_model_leaf_padding ~= nil, "XmlModel leaf rows should have Indent=48 in data") test.expect(xml_model_indent_binding ~= nil, "XmlModelNode prefab should bind Node.PaddingLeft to DataContext/Indent") - test.expect(icon_card_uses_image ~= nil, "IconCard prefab should render its icon as an image") + test.expect(icon_card_uses_image ~= nil, "IconCard prefab should render its icon as a vector image") test.expect(icon_card_header ~= nil, "IconCard should place its title and icon in a header row") test.expect(icon_card_title_binding ~= nil, "IconCard title should bind from DataContext") test.expect(icon_card_uses_deep_icon_binding ~= nil, "IconCard prefab should bind its icon from DataContext") diff --git a/tests/test_navigationhost.lua b/tests/test_navigationhost.lua index 09d3cedf..0949fa30 100644 --- a/tests/test_navigationhost.lua +++ b/tests/test_navigationhost.lua @@ -42,7 +42,7 @@ local function test_navigationhost_push_pop() test.expect(navigation.CanGoBack, "navigation host should report that it can go back") test.expect(detail.DataContext ~= nil, "pushed page should inherit the card data context") - screen:findChild("GameBack", true):send("Node.LeftButtonUp") + screen:findChild("BackButton", true):send("Node.LeftButtonUp") pump_messages(screen) test.expect(root.Visible, "root page should be restored after pop") diff --git a/tools/blender-export.py b/tools/blender-export.py new file mode 100644 index 00000000..13492a60 --- /dev/null +++ b/tools/blender-export.py @@ -0,0 +1,541 @@ +#!/usr/bin/env python3 +"""Blender -> ORCA exporter. + +Runs inside Blender and writes an ORCA project's 3D assets from the open file: + + Meshes/ FBX export + orca-fbx (tools/fbx-import.cpp): .mesh and + a declaration next to it + Shaders/ one per shader node group that has a ".frag" + text block (GLSL fragment body); ".vert" overrides the + default vertex stage + Materials/ one per material built on such a group. Uniform + values are the group node's inputs; "orca_blend" on the + material is opaque | alpha | additive + Images/ images used by Image Texture nodes + Animations/ one per action, plus Animations.lua describing them + Scenes/ .xml: the node hierarchy with transforms and one + AnimationPlayer per clip (Animations/), as a prefab rooted + in a Node3D + Screens/ .xml: Screen > Viewport3D > Scene with the active camera + and a PrefabView3D showing the prefab. Written once; it belongs + to the app afterwards (the export reports the camera to copy) + package.lua written once, if the project does not have one yet + +Usage: + blender -b scene.blend --python tools/blender-export.py -- + [--name Name] [--collection Name] [--screen Name] [--orca-fbx path] + +or, from a running Blender (e.g. through the Blender MCP): + import runpy + runpy.run_path("tools/blender-export.py")["export"]("") + +Engine behaviour this exporter relies on (see the comments where it does): + * custom uniforms have to be registered project property types + * a shader's own properties declare its uniforms; colour ones are sRGB + * 3D nodes draw in tree order with depth writes on +""" +import argparse +import math +import os +import re +import shutil +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET + +import bpy +from mathutils import Euler, Matrix, Vector + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ORCA_FBX = os.path.join(REPO, "build", "bin", "orca-fbx") + +# Blender is Z up, ORCA is Y up with the camera looking down -Z: (x, y, z) -> (x, z, -y). +# The FBX export bakes the same rotation into the mesh data. +TO_ORCA = Matrix(((1, 0, 0, 0), (0, 0, 1, 0), (0, -1, 0, 0), (0, 0, 0, 1))) +FROM_ORCA = TO_ORCA.inverted() + +UNIFORM_CATEGORY = "Uniform" # custom uniforms are attached properties "Uniform." +BLEND = {"opaque": "Opaque", "alpha": "Alpha", "additive": "Additive"} + +DEFAULT_VERTEX = """ +in vec3 a_position; +in vec2 a_texcoord0; +out vec2 v_texcoord0; +uniform mat4 u_modelViewProjectionTransform; +void main() { + v_texcoord0 = a_texcoord0; + gl_Position = u_modelViewProjectionTransform * vec4(a_position, 1.0); +} +""" + + +def fmt(v): + s = "{:.5f}".format(v).rstrip("0").rstrip(".") + return "0" if s in ("-0", "") else s + + +def vec(values): + return " ".join(fmt(v) for v in values) + + +def hex_color(rgb): + """Linear RGB -> '#RRGGBB'. ORCA linearises colour uniforms with pow(c, 2.2) + (and renders to an sRGB framebuffer), so this is the exact inverse of that.""" + def ch(c): + return max(0, min(255, round(max(c, 0.0) ** (1 / 2.2) * 255))) + return "#{:02X}{:02X}{:02X}".format(*(ch(c) for c in rgb[:3])) + + +def write_xml(path, root, doctype=None): + ET.indent(root, space=" ") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write('\n') + if doctype: + f.write(doctype + "\n") + f.write(ET.tostring(root, encoding="unicode")) + f.write("\n") + + +# ---------------------------------------------------------------- transforms + + +def local_matrix(ob): + """Matrix relative to the parent, as the scene graph sees it.""" + if ob.parent: + return ob.parent.matrix_world.inverted() @ ob.matrix_world + return ob.matrix_world.copy() + + +def orca_trs(m, hint=None, camera=False): + """Blender local matrix -> (translation, XYZ euler in degrees, scale) in ORCA space. + + Mesh nodes get a change of basis on both sides, because their vertex data is + converted too. Cameras look down their local -Z in both programs, so only + their placement is converted. `hint` is the Blender euler, used to pick the + equivalent ORCA euler closest to it (keeps single-axis rotations single-axis). + """ + m = TO_ORCA @ m if camera else TO_ORCA @ m @ FROM_ORCA + loc, rot, scale = m.decompose() + compat = Euler((hint[0], hint[2], -hint[1]), 'XYZ') if hint is not None and not camera else Euler((0, 0, 0), 'XYZ') + eul = rot.to_matrix().to_euler('XYZ', compat) + return tuple(loc), tuple(math.degrees(a) for a in eul), tuple(scale) + + +def transform_attr(ob): + t, r, s = orca_trs(local_matrix(ob), ob.rotation_euler if ob.rotation_mode == 'XYZ' else None, ob.type == 'CAMERA') + return "{} {} {}".format(vec(t), vec(r), vec(s)) + + +# ---------------------------------------------------------------- shaders and materials + + +def shader_node(mat): + """The group node that defines an exportable material, or None.""" + if not mat or not mat.node_tree: + return None + for node in mat.node_tree.nodes: + if node.type == 'GROUP' and node.node_tree and bpy.data.texts.get(node.node_tree.name + ".frag"): + return node + return None + + +def uniforms(node): + """[(name, 'Color' | 'Float', xml value)] from the group node's inputs.""" + out = [] + for sock in node.inputs: + if sock.type == 'RGBA': + out.append((sock.name, "Color", hex_color(sock.default_value))) + elif sock.type == 'VALUE': + out.append((sock.name, "Float", fmt(sock.default_value))) + return out + + +def group_uniforms(group): + """Same, from a group's interface defaults.""" + out = [] + for item in group.interface.items_tree: + if item.item_type != 'SOCKET' or item.in_out != 'INPUT': + continue + if item.socket_type == 'NodeSocketColor': + out.append((item.name, "Color", hex_color(item.default_value))) + elif item.socket_type == 'NodeSocketFloat': + out.append((item.name, "Float", fmt(item.default_value))) + return out + + +def export_shader(group, path): + # A shader's own properties are its uniform declarations: ORCA emits the GLSL + # "uniform" lines from them and treats the colour ones as sRGB. The text + # block's hand-written declarations of the same names would clash, so drop them. + declared = group_uniforms(group) + attrs = {"Name": group.name} + for name, _kind, value in declared: + attrs["{}.{}".format(UNIFORM_CATEGORY, name)] = value + names = "|".join(re.escape(n) for n, _k, _v in declared) + frag = bpy.data.texts[group.name + ".frag"].as_string() + if names: + frag = re.sub(r"^\s*uniform\s+\w+\s+(?:{})\s*;[^\n]*\n".format(names), "", frag, flags=re.MULTILINE) + root = ET.Element("Shader", attrs) + vert = bpy.data.texts.get(group.name + ".vert") + ET.SubElement(root, "VertexShader").text = vert.as_string() if vert else DEFAULT_VERTEX + ET.SubElement(root, "FragmentShader", {"Out": "FragColor"}).text = "\n" + frag + write_xml(path, root) + + +def export_image(image, image_dir): + os.makedirs(image_dir, exist_ok=True) + name = os.path.splitext(bpy.path.basename(image.filepath) or image.name)[0] + ".png" + src = bpy.path.abspath(image.filepath) if image.filepath else "" + if src.lower().endswith(".png") and os.path.exists(src): + shutil.copyfile(src, os.path.join(image_dir, name)) + else: + image.save(filepath=os.path.join(image_dir, name)) # packed or non-PNG: let Blender write it + return os.path.splitext(name)[0] + + +def export_material(mat, node, project, out_dir): + attrs = {"Name": mat.name, "Shader": "{}/Shaders/{}".format(project, node.node_tree.name), + "BlendMode": BLEND[mat.get("orca_blend", "opaque")]} + for name, _kind, value in uniforms(node): + attrs["{}.{}".format(UNIFORM_CATEGORY, name)] = value + images = [n.image for n in mat.node_tree.nodes if n.type == 'TEX_IMAGE' and n.image] + if images: + attrs["Texture"] = "{}/Images/{}".format(project, export_image(images[0], os.path.join(out_dir, "Images"))) + write_xml(os.path.join(out_dir, "Materials", mat.name + ".xml"), ET.Element("Material", attrs)) + + +# ---------------------------------------------------------------- meshes + + +def export_meshes(meshes, project, mesh_dir, orca_fbx): + """FBX -> orca-fbx, then one declaration per file.""" + if os.path.isdir(mesh_dir): + shutil.rmtree(mesh_dir) + with tempfile.TemporaryDirectory() as tmp: + fbx = os.path.join(tmp, "export.fbx") + view_layer = bpy.context.view_layer + previous = [(o, o.select_get()) for o in view_layer.objects] + for o in view_layer.objects: + o.select_set(o in meshes) + try: + # Y up / -Z forward with the space transform baked rotates the vertex + # data itself into ORCA space; FBX_SCALE_ALL keeps units 1:1. + bpy.ops.export_scene.fbx( + filepath=fbx, use_selection=True, object_types={'MESH'}, axis_forward='-Z', axis_up='Y', + bake_space_transform=True, apply_scale_options='FBX_SCALE_ALL', global_scale=1.0, + mesh_smooth_type='OFF', use_mesh_modifiers=True, bake_anim=False, add_leaf_bones=False) + finally: + for o, sel in previous: + o.select_set(sel) + done = subprocess.run([orca_fbx, fbx, mesh_dir], capture_output=True, text=True) + if done.returncode != 0: + raise RuntimeError("orca-fbx failed: " + (done.stderr or done.stdout)) + for ob in meshes: + if not os.path.exists(os.path.join(mesh_dir, ob.name + ".mesh")): + raise RuntimeError("orca-fbx wrote no mesh for " + ob.name) + write_xml(os.path.join(mesh_dir, ob.name + ".xml"), + ET.Element("Mesh", {"Name": ob.name, "Source": "{}/Meshes/{}.mesh".format(project, ob.name)})) + + +# ---------------------------------------------------------------- animation + +TRANSFORM_CHANNELS = (("location", "LayoutTransformTranslation"), ("rotation_euler", "LayoutTransformRotation"), + ("scale", "LayoutTransformScale")) +SOCKET_PATH = re.compile(r'nodes\["(.+?)"\]\.inputs\[(\d+)\]\.default_value') + + +def slot_fcurves(action, slot): + if hasattr(action, "fcurves"): # Blender < 5 + return list(action.fcurves) + from bpy_extras import anim_utils + bag = anim_utils.action_get_channelbag_for_slot(action, slot) + return list(bag.fcurves) if bag else [] + + +def animated_ids(action): + """[(id, fcurves)] for every object and node tree driven by the action.""" + out = [] + owners = list(bpy.data.objects) + [m.node_tree for m in bpy.data.materials if m.node_tree] + for owner in owners: + ad = owner.animation_data + if ad and ad.action == action: + out.append((owner, slot_fcurves(action, getattr(ad, "action_slot", None)))) + return out + + +def key_frames(fcurves): + """Frames to export: the keys themselves if every segment is linear, else every frame.""" + frames = sorted({kp.co[0] for fc in fcurves for kp in fc.keyframe_points}) + linear = all(kp.interpolation == 'LINEAR' for fc in fcurves for kp in fc.keyframe_points) + if linear or len(frames) < 2: + return frames + return [float(f) for f in range(math.floor(frames[0]), math.ceil(frames[-1]) + 1)] + + +def object_curves(ob, fcurves): + """[(property, [(frame, (x, y, z))])] for the animated transform groups of an object.""" + by_path = {} + for fc in fcurves: + by_path.setdefault(fc.data_path, {})[fc.array_index] = fc + animated = [(path, prop) for path, prop in TRANSFORM_CHANNELS if path in by_path] + if not animated: + return [] + frames = key_frames([fc for path, _p in animated for fc in by_path[path].values()]) + + def channel(path, current, frame): + return [by_path.get(path, {}).get(i).evaluate(frame) if i in by_path.get(path, {}) else current[i] for i in range(3)] + + samples = [] + for frame in frames: + loc = channel("location", ob.location, frame) + rot = channel("rotation_euler", ob.rotation_euler, frame) + scale = channel("scale", ob.scale, frame) + basis = Matrix.LocRotScale(Vector(loc), Euler(rot, ob.rotation_mode), Vector(scale)) + samples.append((frame, orca_trs(ob.matrix_parent_inverse @ basis, rot))) + index = {"location": 0, "rotation_euler": 1, "scale": 2} + return [(prop, [(frame, trs[index[path]]) for frame, trs in samples]) for path, prop in animated] + + +def uniform_curves(tree, fcurves): + """[(uniform name, [(frame, (value,))])] for animated shader inputs of a material node tree.""" + out = [] + for fc in fcurves: + m = SOCKET_PATH.fullmatch(fc.data_path) + if not m or m.group(1) not in tree.nodes: + continue + sock = tree.nodes[m.group(1)].inputs[int(m.group(2))] + out.append((sock.name, [(f, (fc.evaluate(f),)) for f in key_frames([fc])])) + return out + + +def export_animations(objects, node_path, project, out_dir): + """Write one clip per action. Returns [(action, duration, {object name: [uniform names]})].""" + fps = bpy.context.scene.render.fps / bpy.context.scene.render.fps_base + exported = set(objects) + clips = [] + for action in bpy.data.actions: + curves = [] # (target object, property, keys) + node_uniforms = {} + for owner, fcurves in animated_ids(action): + if isinstance(owner, bpy.types.Object): + if owner in exported: + curves += [(owner, prop, keys) for prop, keys in object_curves(owner, fcurves)] + continue + users = [o for o in exported if o.type == 'MESH' and any( + s.material and s.material.node_tree == owner for s in o.material_slots)] + for name, keys in uniform_curves(owner, fcurves): + for ob in users: + curves.append((ob, name, keys)) + node_uniforms.setdefault(ob.name, []).append(name) + if not curves: + continue + first = min(k[0][0] for _o, _p, k in curves) + last = max(k[-1][0] for _o, _p, k in curves) + duration = (last - first) / fps + root = ET.Element("AnimationClip", {"Name": action.name, "Mode": "PlayOnce", "StartTime": "0", "StopTime": fmt(duration)}) + for ob, prop, keys in curves: + # Players sit under /Animations/, hence the two steps up. + curve = ET.SubElement(root, "AnimationCurve", { + "Name": "{}.{}".format(ob.name, prop), "Path": "../../" + node_path[ob], "Property": prop}) + frames = ET.SubElement(curve, "AnimationCurve.Keyframes") + for frame, value in keys: + value = list(value) + [0.0] * (4 - len(value)) + ET.SubElement(frames, "Keyframe", {"Time": fmt((frame - first) / fps), "Value": vec(value), "TangentMode": "1"}) + write_xml(os.path.join(out_dir, "Animations", action.name + ".xml"), root) + clips.append((action, duration, node_uniforms)) + return clips + + +def write_animation_index(clips, project, screen, out_dir): + """Animations.lua: what each clip stands for, so the app can drive it from a signal.""" + lines = ["-- Generated by tools/blender-export.py. One entry per exported animation clip.", + "-- Scrub a clip by setting CurrentTime on its player node: time = Duration * (value - Min) / (Max - Min).", + "return {"] + for action, duration, _u in clips: + fields = ['Player = "Animations/{}"'.format(action.name), "Duration = {}".format(fmt(duration))] + if "orca_signal" in action: + fields += ['Signal = "{}"'.format(action["orca_signal"]), "Min = {}".format(fmt(action.get("orca_min", 0.0))), + "Max = {}".format(fmt(action.get("orca_max", 1.0)))] + lines.append("\t{} = {{ {} }},".format(action.name, ", ".join(fields))) + lines.append("}") + with open(os.path.join(out_dir, "Animations.lua"), "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + +# ---------------------------------------------------------------- scene + + +def camera_element(ob): + scene = bpy.context.scene + aspect = scene.render.resolution_x / scene.render.resolution_y + cam = ob.data + if cam.sensor_fit == 'VERTICAL' or (cam.sensor_fit == 'AUTO' and aspect < 1): + fov_v = 2 * math.atan(cam.sensor_height / 2 / cam.lens) + else: + fov_v = 2 * math.atan(math.tan(math.atan(cam.sensor_width / 2 / cam.lens)) / aspect) + return ET.Element("Camera", { + "Name": ob.name, "Fov": fmt(math.degrees(fov_v)), "FovType": "Yfov", + "ZNear": fmt(cam.clip_start), "ZFar": fmt(cam.clip_end), "LayoutTransform": transform_attr(ob)}) + + +def export_scene(objects, project, screen, out_dir, clips): + scene = bpy.context.scene + width, height = scene.render.resolution_x, scene.render.resolution_y + exported = set(objects) + camera = scene.camera if scene.camera in exported else None + cam_inv = camera.matrix_world.inverted() if camera else Matrix() + animated_uniforms = {} + for _a, _d, node_uniforms in clips: + for name, names in node_uniforms.items(): + animated_uniforms.setdefault(name, []).extend(names) + + def depth(ob): + return (cam_inv @ ob.matrix_world.translation).z # camera looks down -Z: most negative is farthest + + def element(ob): + attrs = {"Name": ob.name, "LayoutTransform": transform_attr(ob)} + node = shader_node(ob.material_slots[0].material) if ob.type == 'MESH' and ob.material_slots else None + if node: + mat = ob.material_slots[0].material + attrs["Mesh"] = "{}/Meshes/{}".format(project, ob.name) + attrs["Material"] = "{}/Materials/{}".format(project, mat.name) + # Animated uniforms live on the node: every node loads its own material + # instance, and node values take precedence over the material's. + values = {n: v for n, _k, v in uniforms(node)} + for name in animated_uniforms.get(ob.name, []): + attrs["{}.{}".format(UNIFORM_CATEGORY, name)] = values[name] + el = ET.Element("Model3D", attrs) + else: + el = ET.Element("Node3D", attrs) + # Nodes draw in tree order with depth writes on, so siblings go back to front. + for child in sorted((c for c in ob.children if c in exported and c.type != 'CAMERA'), key=depth): + el.append(element(child)) + return el + + # Prefab: everything the app should not have to touch after an export. + prefab = ET.Element("Node3D", {"Name": screen}) + for ob in sorted((o for o in objects if o.parent not in exported and o.type != 'CAMERA'), key=depth): + prefab.append(element(ob)) + if clips: + # An element is an object of its own, and curve paths are + # relative to it: /Animations/. + players = ET.SubElement(prefab, "Node3D", {"Name": "Animations"}) + for action, _duration, _u in clips: + ET.SubElement(players, "AnimationPlayer", { + "Name": action.name, "Clip": "{}/Animations/{}".format(project, action.name)}) + write_xml(os.path.join(out_dir, "Scenes", screen + ".xml"), prefab) + + # Screen: scaffolded once. The camera lookup only checks the scene's direct + # children, so the camera lives here rather than in the prefab. + cam_el = camera_element(camera) if camera else None + path = os.path.join(out_dir, "Screens", screen + ".xml") + if not os.path.exists(path): + root = ET.Element("Screen", {"Name": screen, "Width": str(width), "Height": str(height), "ClearColor": "#000000"}) + viewport = ET.SubElement(root, "Viewport3D", {"Name": "Viewport", "Width": str(width), "Height": str(height)}) + scene_el = ET.SubElement(viewport, "Scene", {"Name": "Scene"}) + if cam_el is not None: + scene_el.set("Camera", camera.name) + scene_el.append(cam_el) + ET.SubElement(scene_el, "PrefabView3D", {"Name": screen + "View", "Prefab": "{}/Scenes/{}".format(project, screen)}) + write_xml(path, root, '') + return ET.tostring(cam_el, encoding="unicode") if cam_el is not None else None + + +def node_paths(objects): + exported = set(objects) + + def path(ob): + return (path(ob.parent) + "/" if ob.parent in exported else "") + ob.name + return {ob: path(ob) for ob in objects} + + +# ---------------------------------------------------------------- project + + +def property_types(materials): + """Uniforms have to exist as project property types before a material can set them.""" + types = {} + for _mat, node in materials: + for name, kind, _value in uniforms(node): + if types.setdefault(name, kind) != kind: + raise RuntimeError("uniform {} is both a colour and a float".format(name)) + return ["\t{{ Name = \"{}\", Category = \"{}\", DataType = \"{}\" }},".format(n, UNIFORM_CATEGORY, k) + for n, k in sorted(types.items())] + + +def write_package(out_dir, project, screen, materials): + scene = bpy.context.scene + path = os.path.join(out_dir, "package.lua") + types = property_types(materials) + if os.path.exists(path): + text = open(path, encoding="utf-8").read() + return [t.strip() for t in types if t.split('"')[1] not in text] + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join([ + 'Name = "{}"'.format(project), + 'StartupScreen = "{}/Screens/{}"'.format(project, screen), + "WindowWidth = {}".format(scene.render.resolution_x), + "WindowHeight = {}".format(scene.render.resolution_y), + "ScreenLibrary = { IsExternal = true }", + "SystemMessages = {", + '\t{ Message="KeyDown", Key="q", Command="return" },', + '\t{ Message="WindowClosed", Command="return" },', + '\t{ Message="RequestReload", Command="window:refresh()" },', + "}", + "EnginePlugins = {", + '\t{ Name="orca.UIKit" },', + '\t{ Name="orca.SceneKit" },', + "}", + "-- Shader uniforms. tools/blender-export.py reports any that are missing here.", + "PropertyTypes = {"] + types + ["}", ""])) + return [] + + +def export(out_dir, project=None, collection=None, screen="Cluster", orca_fbx=ORCA_FBX): + out_dir = os.path.abspath(out_dir) + project = project or "".join(w.capitalize() for w in re.split(r"[^A-Za-z0-9]+", os.path.basename(out_dir)) if w) + coll = bpy.data.collections.get(collection) if collection else None + objects = list(coll.all_objects) if coll else list(bpy.context.scene.objects) + bpy.context.view_layer.update() + + meshes = [o for o in objects if o.type == 'MESH' and o.material_slots and shader_node(o.material_slots[0].material)] + skipped = [o.name for o in objects if o.type == 'MESH' and o not in meshes] + materials = {} + for ob in meshes: + mat = ob.material_slots[0].material + materials[mat.name] = (mat, shader_node(mat)) + groups = {node.node_tree.name: node.node_tree for _m, node in materials.values()} + + for sub in ("Shaders", "Materials", "Animations"): + shutil.rmtree(os.path.join(out_dir, sub), ignore_errors=True) + export_meshes(meshes, project, os.path.join(out_dir, "Meshes"), orca_fbx) + for group in groups.values(): + export_shader(group, os.path.join(out_dir, "Shaders", group.name + ".xml")) + for mat, node in materials.values(): + export_material(mat, node, project, out_dir) + clips = export_animations(objects, node_paths(objects), project, out_dir) + if clips: + write_animation_index(clips, project, screen, out_dir) + camera = export_scene(objects, project, screen, out_dir, clips) + missing = write_package(out_dir, project, screen, list(materials.values())) + return { + "project": project, "out_dir": out_dir, "meshes": len(meshes), "shaders": sorted(groups), + "materials": len(materials), "clips": {a.name: round(d, 4) for a, d, _u in clips}, + "camera": camera, + "skipped_meshes_without_exportable_material": skipped, + "property_types_missing_from_package_lua": missing, + } + + +if __name__ == "__main__": + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + parser = argparse.ArgumentParser(prog="blender-export.py", description="Export the open Blender file as ORCA assets.") + parser.add_argument("project_dir") + parser.add_argument("--name", help="project name used in asset paths (default: from the directory name)") + parser.add_argument("--collection", help="export only this collection") + parser.add_argument("--screen", default="Cluster", help="name of the generated screen") + parser.add_argument("--orca-fbx", default=ORCA_FBX) + args = parser.parse_args(argv) + print(export(args.project_dir, args.name, args.collection, args.screen, args.orca_fbx)) diff --git a/tools/create-app.py b/tools/create-app.py index feef75de..f3d1753c 100644 --- a/tools/create-app.py +++ b/tools/create-app.py @@ -32,60 +32,72 @@ def ask_choice(q, choices, default=None): print(f" Please enter one of: {', '.join(choices)}") # --------------------------------------------------------------------------- -# package.xml generators +# package.lua generators +# +# The runtime loads only /package.lua. It is a declarative Lua chunk +# whose global assignments set properties of the Project object (see the +# Project class in source/filesystem/filesystem.cgen). # --------------------------------------------------------------------------- -def _system_messages(parent): - msgs = ET.SubElement(parent, "MessageLibrary", {"Name": "Messages"}) - for attrs, text in [ - ({"Message": "KeyDown", "Key": "q"}, "return"), - ({"Message": "WindowClosed"}, "return"), - ({"Message": "RequestReload"}, "window:refresh()"), - ]: - ET.SubElement(msgs, "SystemMessage", attrs).text = text - -def package_xml_script(name, flags, width, height): - """package.xml for moonscript/lua projects (ProjectReferenceLibrary style).""" - root = ET.Element("Project", { - "Name": name, - "StartupScreen": f"{name}/App", - "Width": str(width), - "Height": str(height), - }) - refs = ET.SubElement(root, "ProjectReferenceLibrary") - for k, v in { +SYSTEM_MESSAGES = [ + {"Message": "KeyDown", "Key": "q", "Command": "return"}, + {"Message": "WindowClosed", "Command": "return"}, + {"Message": "RequestReload", "Command": "window:refresh()"}, +] + +def _lua_value(v): + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, int): + return str(v) + s = str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + return f'"{s}"' + +def _lua_table(fields): + return "{ " + ", ".join(f"{k} = {_lua_value(v)}" for k, v in fields.items()) + " }" + +def _lua_array(name, items): + lines = [f"{name} = {{"] + lines += [f"\t{_lua_table(item)}," for item in items] + lines.append("}") + return lines + +def package_lua_script(name, flags, width, height): + """package.lua for moonscript/lua projects (ProjectReferences style).""" + refs = { "applications": "applications", "assets": "assets", "routing": "lib/routing", "model": "model", "config": "config", - }.items(): - ET.SubElement(refs, "ProjectReferenceItem", {"Name": k}).text = v + } for k in flags: if flags[k]: - ET.SubElement(refs, "ProjectReferenceItem", {"Name": k}).text = f"lib/{k}" - ET.SubElement(root, "FontLibrary", {"Name": "fonts"}) - _system_messages(root) - ET.indent(root, space=" ") - return ET.ElementTree(root) - -def package_xml_xml(name, width, height): - """package.xml for xml projects (ScreenLibrary style, like Example/Volcano).""" - root = ET.Element("Project", { - "Name": name, - "StartupScreen": f"{name}/Screens/App", - }) - ET.SubElement(root, "ScreenLibrary", {"Name": "Screens", "IsExternal": "true"}) - ET.SubElement(root, "FontLibrary", {"Name": "fonts"}) - sysmsg = ET.SubElement(root, "Project.SystemMessages") - for attrs in [ - {"Message": "KeyDown", "Key": "q", "Command": "return"}, - {"Message": "WindowClosed", "Command": "return"}, - {"Message": "RequestReload", "Command": "window:refresh()"}, - ]: - ET.SubElement(sysmsg, "SystemMessage", attrs) - ET.indent(root, space=" ") - return ET.ElementTree(root) + refs[k] = f"lib/{k}" + lines = [ + f"Name = {_lua_value(name)}", + f"StartupScreen = {_lua_value(f'{name}/App')}", + f"WindowWidth = {_lua_value(width)}", + f"WindowHeight = {_lua_value(height)}", + ] + lines += _lua_array("ProjectReferences", [{"Name": k, "Path": v} for k, v in refs.items()]) + lines.append(f"FontLibrary = {_lua_table({'Name': 'fonts'})}") + lines += _lua_array("SystemMessages", SYSTEM_MESSAGES) + return "\n".join(lines) + "\n" + +def package_lua_xml(name, width, height): + """package.lua for xml projects (ScreenLibrary style, like Example/Adventure).""" + lines = [ + f"Name = {_lua_value(name)}", + f"StartupScreen = {_lua_value(f'{name}/Screens/App')}", + f"WindowWidth = {_lua_value(width)}", + f"WindowHeight = {_lua_value(height)}", + f"ScreenLibrary = {_lua_table({'IsExternal': True})}", + f"FontLibrary = {_lua_table({'Name': 'fonts'})}", + ] + lines += _lua_array("SystemMessages", SYSTEM_MESSAGES) + lines += _lua_array("EnginePlugins", [{"Name": "orca.UIKit"}]) + return "\n".join(lines) + "\n" # --------------------------------------------------------------------------- # File generators: moonscript @@ -290,16 +302,14 @@ def main(): if language == "moonscript": create_moonscript(root_dir, name, flags) - package_xml_script(name, flags, width, height).write( - root_dir / "package.xml", encoding="utf-8", xml_declaration=True) + package = package_lua_script(name, flags, width, height) elif language == "lua": create_lua(root_dir, name, flags) - package_xml_script(name, flags, width, height).write( - root_dir / "package.xml", encoding="utf-8", xml_declaration=True) + package = package_lua_script(name, flags, width, height) elif language == "xml": create_xml(root_dir, name, width, height) - package_xml_xml(name, width, height).write( - root_dir / "package.xml", encoding="utf-8", xml_declaration=True) + package = package_lua_xml(name, width, height) + (root_dir / "package.lua").write_text(package, encoding="utf-8") active_libs = [k for k, v in flags.items() if v] summary = f"language={language}" diff --git a/tools/fbx-import.cpp b/tools/fbx-import.cpp index ebace2cc..ce6bc19c 100644 --- a/tools/fbx-import.cpp +++ b/tools/fbx-import.cpp @@ -9,13 +9,20 @@ extern "C" { #pragma GCC diagnostic ignored "-Wdocumentation-pedantic" #endif +// FBX SDK 2020.2 misspells this member inside a template that is never +// instantiated; recent clang rejects it anyway. +#define mLefttChild mLeftChild #include +#undef mLefttChild #if defined(__clang__) #pragma GCC diagnostic pop #endif #include +#include +#include +#include #include @@ -23,6 +30,9 @@ extern "C" { static int const IDMESHHEADER = MAKE_FOURCC('M', 'E', 'S', 'H'); +// Mirrors include/renderer.h, which this tool does not pull in. +struct color32 { unsigned char r, g, b, a; }; + typedef uint32_t DRAWINDEX, *PDRAWINDEX; typedef uint32_t DRAWSURFATTR, *PSURFATTR; @@ -120,7 +130,7 @@ static void API_Copy(const FbxColor &a, struct color32 &b) { color[0] = static_cast(a[0] * 255); color[1] = static_cast(a[1] * 255); color[2] = static_cast(a[2] * 255); - color[3] = 255; + color[3] = static_cast(a[3] * 255); memcpy(&b, color, 4); } @@ -364,8 +374,9 @@ static void API_SaveModel(FbxMesh *mesh, FbxScene *scene, PDRAWSURF model, lpcSt fwrite(&IDMESHHEADER, sizeof(IDMESHHEADER), 1, fp); int num_uv = std::min(3, mesh->GetElementUVCount()); - - uint32_t numAttributes = 4 + num_uv; + bool has_color = mesh->GetElementVertexColorCount() > 0; + + uint32_t numAttributes = 4 + num_uv + (has_color ? 1 : 0); fwrite(&model->numVertices, sizeof(model->numVertices), 1, fp); fwrite(&model->numIndices, sizeof(model->numIndices), 1, fp); @@ -397,6 +408,12 @@ static void API_SaveModel(FbxMesh *mesh, FbxScene *scene, PDRAWSURF model, lpcSt writeint(VERTEX_ATTR_DATATYPE_FLOAT32, fp); for (int i = 0; i < model->numVertices; writevec3(&model->vertices[i++].tangent, fp, 1, axis)); + if (has_color) { + writeint(VERTEX_SEMANTIC_COLOR, fp); + writeint(VERTEX_ATTR_DATATYPE_UINT8 | VERTEX_ATTR_DATATYPE_NORMALIZED, fp); + for (int i = 0; i < model->numVertices; fwrite(&model->vertices[i++].color, sizeof(struct color32), 1, fp)); + } + for (int j = 0; j < num_uv; j++) { writeint(VERTEX_SEMANTIC_TEXCOORD0+j, fp); writeint(VERTEX_ATTR_DATATYPE_FLOAT32, fp); @@ -440,7 +457,7 @@ static void ExportMesh(FbxMesh *mesh, lpcString_t directory) { FbxAMatrix rotationMatrix; rotationMatrix.SetR(geomTrans.GetR()); // Extract just rotation - for (int i = 0, v = 0; i < numPolygons; i++, v += mesh->GetPolygonSize(i)) { + for (int i = 0, v = 0; i < numPolygons; v += mesh->GetPolygonSize(i), i++) { std::vector *ptrIndices = &outIndices; if (materials) { auto k = materials->GetIndexArray().GetAt(i); @@ -460,6 +477,7 @@ static void ExportMesh(FbxMesh *mesh, lpcString_t directory) { for (int k = 0; k < 3; k++) { DRAWVERT vtx; uint32_t index; + memset(&vtx, 0, sizeof(vtx)); auto index0 = v + k; auto index1 = mesh->GetPolygonVertex(i, k); @@ -549,25 +567,19 @@ void ConvertCollada(lpcString_t input, lpcString_t output) {} int main(int argc, lpcString_t argv[]) { if (argc < 3) { - Con_Error("fbxtool usage:"); - Con_Error("fbxtool [-options] [input] [output]"); - return 0; + fprintf(stderr, "usage: orca-fbx \n" + "Writes one .mesh per mesh node into the directory.\n"); + return 1; } lpcString_t input = argv[1]; lpcString_t output = argv[2]; - -// input = "/Users/igor/Developer/titanium-ui/Titanium/MB/03_Contexts/01_Classic/00_Resources/Views/ArtRing/3D/230616_MB_CC_ART_Ring_+_Marker v2.fbx"; -// output = "/Users/igor/Developer/titanium-ui-orca/Titanium/MB/03_Contexts/01_Classic/00_Resources/Views/ArtRing/3D/230616_MB_CC_ART_Ring_+_Marker v2"; - -// input = "/Users/igor/Developer/titanium-ui/Titanium/MB/03_Contexts/01_Classic/00_Resources/Views/Tube/3D/MB_CC_Tube_Shine.fbx"; -// output = "/Users/igor/Developer/titanium-ui-orca/Titanium/MB/03_Contexts/01_Classic/00_Resources/Views/Tube/3D/MB_CC_Tube_Shine"; + int status = 0; -// input = "/Users/igor/Developer/titanium-ui/Titanium/MB/02_GlobalElements/MB_Startup/3D/MB_Star.fbx"; -// output = "/Users/igor/Developer/titanium-ui-orca/Titanium/MB/02_GlobalElements/MB_Startup/3D/MB_Star"; + if (mkdir(output, 0755) != 0 && errno != EEXIST) { + Con_Error("Can not create directory %s", output); + return 1; + } - input = "/Users/igor/Developer/titanium-ui/Titanium/MB/02_GlobalElements/MB_Startup/3D/light_shadow.fbx"; - output = "/Users/igor/Developer/titanium-ui-orca/Titanium/MB/02_GlobalElements/MB_Startup/3D/light_shadow"; - FbxManager *manager = FbxManager::Create(); FbxImporter *importer = FbxImporter::Create(manager, ""); FbxIOSettings *iosettings = FbxIOSettings::Create(manager, IOSROOT); @@ -590,16 +602,18 @@ int main(int argc, lpcString_t argv[]) { } else { FbxString error = importer->GetStatus().GetErrorString(); FBXSDK_printf("FbxImporter::Import(): %s\n\n", error.Buffer()); + status = 1; } scene->Destroy(); } else { FbxString error = importer->GetStatus().GetErrorString(); FBXSDK_printf("FbxImporter::Initialize(): %s\n\n", error.Buffer()); + status = 1; } iosettings->Destroy(); importer->Destroy(); manager->Destroy(); - return 0; + return status; }