From 2b65b69119a90f104e11096a4d27b6c919c8dbf1 Mon Sep 17 00:00:00 2001 From: CabinetOnFire <> Date: Sat, 1 Aug 2026 21:56:52 +0200 Subject: [PATCH 01/11] Implements extension manager --- Taskfile.yml | 8 + extensionapi/extensionapi.go | 148 +++++ extensionapi/go.mod | 22 + extensionapi/go.sum | 54 ++ extensions/README.md | 53 ++ extensions/example/extension.json | 13 + go.mod | 14 + go.sum | 39 ++ internal/app/action.go | 11 + internal/app/action_user.go | 10 + internal/app/app.go | 32 +- internal/app/config_project.go | 20 +- internal/app/extensions/manager.go | 615 ++++++++++++++++++ internal/app/extensions/package.go | 148 +++++ internal/app/render/brush/batch.go | 3 + internal/app/render/brush/bilinear.go | 47 ++ internal/app/render/brush/brush.go | 35 +- internal/app/render/brush/shader.go | 43 ++ internal/app/render/brush/shape.go | 23 + internal/app/render/bucket.go | 33 +- internal/app/render/extension_passes.go | 144 ++++ internal/app/render/render.go | 35 +- internal/app/ui/cpwsarea/wsarea.go | 9 + .../ui/cpwsarea/wsmap/pmap/editor/commit.go | 2 + .../ui/cpwsarea/wsmap/pmap/editor/editor.go | 2 + internal/app/ui/cpwsarea/wsmap/pmap/pmap.go | 36 +- .../wsmap/pmap/psettings/psettings.go | 2 + .../wsmap/pmap/psettings/screenshot.go | 6 + .../cpwsarea/wsmap/pmap/tilemenu/process.go | 18 + .../cpwsarea/wsmap/pmap/tilemenu/tilemenu.go | 9 +- internal/app/ui/menu/menu.go | 81 ++- tools/sdmmpack/go.mod | 3 + tools/sdmmpack/main.go | 94 +++ 33 files changed, 1743 insertions(+), 69 deletions(-) create mode 100644 extensionapi/extensionapi.go create mode 100644 extensionapi/go.mod create mode 100644 extensionapi/go.sum create mode 100644 extensions/README.md create mode 100644 extensions/example/extension.json create mode 100644 internal/app/extensions/manager.go create mode 100644 internal/app/extensions/package.go create mode 100644 internal/app/render/brush/bilinear.go create mode 100644 internal/app/render/extension_passes.go create mode 100644 tools/sdmmpack/go.mod create mode 100644 tools/sdmmpack/main.go diff --git a/Taskfile.yml b/Taskfile.yml index bf53218b..ac03107e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -55,6 +55,14 @@ tasks: - task: build-parser - task: build-editor + package-extension: + desc: Package prebuilt extension commands into a .sdmmext archive + dir: tools/sdmmpack + requires: + vars: [EXTENSION_DIR, EXTENSION_OUTPUT] + cmds: + - go run . -source "{{.EXTENSION_DIR}}" -manifest "{{.EXTENSION_DIR}}/extension.json" -output "{{.EXTENSION_OUTPUT}}" + run: deps: - build-parser diff --git a/extensionapi/extensionapi.go b/extensionapi/extensionapi.go new file mode 100644 index 00000000..cd280010 --- /dev/null +++ b/extensionapi/extensionapi.go @@ -0,0 +1,148 @@ +// Package extensionapi defines the RPC contract between StrongDMM and Go extensions. +package extensionapi + +import ( + "net/rpc" + + "github.com/hashicorp/go-plugin" +) + +const ( + ProtocolVersion = 1 + PluginName = "strongdmm.extension" + StageMap = "core.map" + StageSelection = "core.selection" + StageOverlays = "core.overlays" + + CapabilityRender = "render" + CapabilityContextMenu = "context-menu" +) + +type Color struct{ R, G, B float32 } +type AtomDefinition struct { + ID uint32 + Path string + Vars map[string]string +} +type Tile struct { + X, Y, Z int + Atoms []uint32 +} +type Map struct { + Width, Height, Levels int + Definitions []AtomDefinition + Tiles []Tile +} +type Vertex struct{ X, Y, R, G, B, A float32 } +type BilinearQuad struct { + X1, Y1, X2, Y2 float32 + SouthWest, SouthEast, NorthWest, NorthEast Color +} +type Mesh struct { + Vertices []Vertex + Indices []uint32 + Quads []BilinearQuad +} +type RenderCommand struct { + ID string + Kind string + Mesh Mesh +} +type RenderPass struct { + ID string + After, Before []string + Blend string + Commands []RenderCommand +} +type RenderPatch struct { + PassID string + Upsert []RenderCommand + Remove []string +} +type RenderUpdate struct { + Replace []RenderPass + Patches []RenderPatch + RemovePasses []string +} +type MapUpdate struct { + Width, Height, Levels int + Definitions []AtomDefinition + Tiles []Tile +} +type ContextTurf struct { + X, Y, Z int + Atoms []AtomDefinition +} +type ContextMenuRequest struct{ Turf ContextTurf } +type ContextMenuAction struct { + ID, Label string + Enabled bool +} +type ContextMenuResponse struct{ Actions []ContextMenuAction } +type ActionRequest struct { + ID string + Turf ContextTurf +} + +// Request is a versioned extension operation. Extensions ignore operations +// outside the capabilities they declare in their package manifest. +type Request struct { + Version int + Type string + MapID string + Revision uint64 + Settings map[string]bool + Map *Map + Update *MapUpdate + Render *RenderUpdate + Context *ContextMenuRequest + Action *ActionRequest + ContextMenu *ContextMenuResponse + Reason string +} + +// Response carries optional output for a request. Render and context-menu +// operations use their corresponding fields; other operations may return ack. +type Response = Request + +// Extension handles versioned requests for its declared capabilities. +type Extension interface { + Handle(Request) (Response, error) +} + +// HandshakeConfig prevents StrongDMM from connecting to an unrelated process. +var HandshakeConfig = plugin.HandshakeConfig{ + ProtocolVersion: ProtocolVersion, + MagicCookieKey: "STRONGDMM_EXTENSION", + MagicCookieValue: "strongdmm-extension-v1", +} + +// Plugin adapts an Extension to HashiCorp go-plugin's net/rpc transport. +type Plugin struct { + plugin.NetRPCUnsupportedPlugin + Impl Extension +} + +func (p *Plugin) Server(*plugin.MuxBroker) (interface{}, error) { + return &rpcServer{Impl: p.Impl}, nil +} + +func (p *Plugin) Client(_ *plugin.MuxBroker, client *rpc.Client) (interface{}, error) { + return &rpcClient{client: client}, nil +} + +type rpcClient struct{ client *rpc.Client } + +func (c *rpcClient) Handle(request Request) (Response, error) { + var response Response + err := c.client.Call("Plugin.Handle", request, &response) + return response, err +} + +type rpcServer struct{ Impl Extension } + +func (s *rpcServer) Handle(request Request, response *Response) error { + result, err := s.Impl.Handle(request) + *response = result + return err +} diff --git a/extensionapi/go.mod b/extensionapi/go.mod new file mode 100644 index 00000000..a5c0bd23 --- /dev/null +++ b/extensionapi/go.mod @@ -0,0 +1,22 @@ +module github.com/SpaiR/StrongDMM-extension-api + +go 1.24.0 + +require github.com/hashicorp/go-plugin v1.6.3 + +require ( + github.com/fatih/color v1.7.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/hashicorp/go-hclog v0.14.1 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/mattn/go-colorable v0.1.4 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/oklog/run v1.0.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect + google.golang.org/grpc v1.58.3 // indirect + google.golang.org/protobuf v1.36.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/extensionapi/go.sum b/extensionapi/go.sum new file mode 100644 index 00000000..f4b9ac76 --- /dev/null +++ b/extensionapi/go.sum @@ -0,0 +1,54 @@ +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-hclog v0.14.1 h1:nQcJDQwIAGnmoUWp8ubocEX40cCml/17YkF6csQLReU= +github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= +google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/extensions/README.md b/extensions/README.md new file mode 100644 index 00000000..989866ec --- /dev/null +++ b/extensions/README.md @@ -0,0 +1,53 @@ +# StrongDMM extensions + +Extensions are Go executables managed by StrongDMM through HashiCorp +go-plugin's versioned net/rpc transport. They run out of process, so an +extension failure cannot mangle the editor. + +```json +{ + "id": "example.extension", + "name": "Example Extension", + "apiVersion": 1, + "capabilities": ["render", "context-menu"], + "commands": { + "windows-amd64": "bin/windows-amd64/example.exe" + } +} +``` + +Build each command for its target platform: + +```text +go build -o bin/windows-amd64/example.exe . +``` + +Implement `extensionapi.Extension` and call `plugin.Serve` with +`extensionapi.HandshakeConfig` and an `extensionapi.Plugin`. The API exposes +versioned `Request` values and request-shaped responses. Rendering receives map +open/update/close operations, revisions, settings, and render-pass output. The +declared capabilities determine which operations an extension receives: +`render` receives map lifecycle operations and `context-menu` receives requests +for dynamic actions at a turf. Standard output is reserved for go-plugin's +handshake and transport; write extension logs to standard error. + +Context-menu requests provide the clicked turf and its atom definitions. Return +actions with stable extension-local IDs; StrongDMM returns the selected ID in a +context action request. Extensions can ignore request types outside their +declared capabilities. + +`RenderPass` IDs are globally unique. Passes can be placed relative to +`core.map`, `core.selection`, and `core.overlays`; invalid dependencies and +cycles are logged. `mesh` commands support RGBA vertex colors; `bilinear` +commands are intentionally opaque and support normal, multiply, or additive +blending. + +Package prebuilt commands with: + +```text +cd tools/sdmmpack +go run . -source path/to/commands -manifest extension.json -output example.sdmmext +``` + +Extension API versions must match the host. Build and package extensions only +from trusted source. diff --git a/extensions/example/extension.json b/extensions/example/extension.json new file mode 100644 index 00000000..1560cccd --- /dev/null +++ b/extensions/example/extension.json @@ -0,0 +1,13 @@ +{ + "id": "example.extension", + "name": "Example Extension", + "apiVersion": 1, + "commands": { + "windows-amd64": ["bin/windows-amd64/example-extension.exe"], + "linux-amd64": ["bin/linux-amd64/example-extension"], + "darwin-arm64": ["bin/darwin-arm64/example-extension"] + }, + "actions": [ + {"id": "enabled", "menu": "Extensions", "label": "Enable Example Extension", "kind": "toggle-enabled"} + ] +} diff --git a/go.mod b/go.mod index f8b2e69a..4b479b4d 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ module sdmm go 1.24.0 require ( + github.com/SpaiR/StrongDMM-extension-api v0.0.0 github.com/SpaiR/imgui-go v1.12.1-0.20220214190844-a0bad21e1c5d github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a github.com/go-gl/mathgl v1.2.0 + github.com/hashicorp/go-hclog v0.14.1 + github.com/hashicorp/go-plugin v1.6.3 github.com/matishsiao/goInfo v0.0.0-20210923090445-da2e3fa8d45f github.com/mazznoer/csscolorparser v0.1.6 github.com/minio/selfupdate v0.6.0 @@ -17,17 +20,28 @@ require ( golang.design/x/clipboard v0.7.1 ) +replace github.com/SpaiR/StrongDMM-extension-api => ./extensionapi + require ( aead.dev/minisign v0.2.0 // indirect github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.7.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect + github.com/oklog/run v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect golang.org/x/image v0.28.0 // indirect golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect + google.golang.org/grpc v1.58.3 // indirect + google.golang.org/protobuf v1.36.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6fc0b259..4b7b459c 100644 --- a/go.sum +++ b/go.sum @@ -5,10 +5,14 @@ github.com/SpaiR/imgui-go v1.12.1-0.20220214190844-a0bad21e1c5d h1:QajPUffv+BBqN github.com/SpaiR/imgui-go v1.12.1-0.20220214190844-a0bad21e1c5d/go.mod h1:bzOQqDbRE58xRB4EGTlUrIhCTWxNvU7YtJckrFf+HpM= github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I= github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= @@ -16,10 +20,27 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2 github.com/go-gl/mathgl v1.2.0 h1:v2eOj/y1B2afDxF6URV1qCYmo1KW08lAMtTbOn3KXCY= github.com/go-gl/mathgl v1.2.0/go.mod h1:pf9+b5J3LFP7iZ4XXaVzZrCle0Q/vNpB/vDe5+3ulRE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-hclog v0.14.1 h1:nQcJDQwIAGnmoUWp8ubocEX40cCml/17YkF6csQLReU= +github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/matishsiao/goInfo v0.0.0-20210923090445-da2e3fa8d45f h1:B0OD7nYl2FPQEVrw8g2uyc1lGEzNbvrKh7fspGZcbvY= github.com/matishsiao/goInfo v0.0.0-20210923090445-da2e3fa8d45f/go.mod h1:aEt7p9Rvh67BYApmZwNDPpgircTO2kgdmDUoF/1QmwA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= @@ -27,6 +48,8 @@ github.com/mazznoer/csscolorparser v0.1.6 h1:uK6p5zBA8HaQZJSInHgHVmkVBodUAy+6snS github.com/mazznoer/csscolorparser v0.1.6/go.mod h1:OQRVvgCyHDCAquR1YWfSwwaDcM0LhnSffGnlbOew/3I= github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -38,6 +61,7 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:s github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ= github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -61,8 +85,12 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -77,8 +105,19 @@ golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXR golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= +google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/app/action.go b/internal/app/action.go index 0a917b74..42918d91 100644 --- a/internal/app/action.go +++ b/internal/app/action.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "sdmm/internal/app/extensions" "sdmm/internal/app/prefs" "sdmm/internal/app/render" "sdmm/internal/app/ui/cpwsarea/wsmap" @@ -236,6 +237,16 @@ func (a *app) Prefs() prefs.Prefs { return a.preferencesConfig().Prefs } +// Extensions returns the manager for installed extensions. +func (a *app) Extensions() *extensions.Manager { return a.extensions } + +func (a *app) ExtensionMenuActions() []extensions.MenuAction { + if a.extensions == nil { + return nil + } + return a.extensions.MenuActions() +} + // FocusApplicationWindow explicitly moves an OS focus to the current application window. func (a *app) FocusApplicationWindow() { a.masterWindow.Handle().Focus() diff --git a/internal/app/action_user.go b/internal/app/action_user.go index e9d717db..7748d6c2 100644 --- a/internal/app/action_user.go +++ b/internal/app/action_user.go @@ -333,6 +333,16 @@ func (a *app) DoMirrorCanvasCamera() { log.Print("do mirror canvas camera:", pmap.MirrorCanvasCamera) } +// DoExtensionMenuAction executes a extensions menu action (defined by the extension) +func (a *app) DoExtensionMenuAction(id string) { + if a.extensions == nil { + return + } + a.extensions.ExecuteMenuAction(id) + a.layout.WsArea.RefreshExtensions() + log.Print("do extension action:", id) +} + // DoSelfUpdate starts the process of a self update. func (a *app) DoSelfUpdate() { log.Print("do self update") diff --git a/internal/app/app.go b/internal/app/app.go index 4e476071..d4fd8f5b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -10,6 +10,7 @@ import ( "sdmm/internal/app/command" "sdmm/internal/app/config" + "sdmm/internal/app/extensions" "sdmm/internal/app/render/brush" "sdmm/internal/app/ui/dialog" "sdmm/internal/app/ui/layout" @@ -49,11 +50,13 @@ func Start() { } a := app{ - internalDir: internalDir, - logDir: logDir, - backupDir: filepath.FromSlash(internalDir + "/backup"), - configDir: filepath.FromSlash(internalDir + "/config"), + internalDir: internalDir, + logDir: logDir, + backupDir: filepath.FromSlash(internalDir + "/backup"), + configDir: filepath.FromSlash(internalDir + "/config"), + extensionsDir: filepath.FromSlash(internalDir + "/extensions"), } + _ = os.MkdirAll(a.extensionsDir, os.ModePerm) a.masterWindow = window.New(&a) @@ -73,10 +76,11 @@ func Start() { type app struct { masterWindow *window.Window - internalDir string - logDir string - backupDir string - configDir string + internalDir string + logDir string + backupDir string + configDir string + extensionsDir string tmpShouldClose bool tmpWindowCond imgui.Condition @@ -96,8 +100,9 @@ type app struct { commandStorage *command.Storage clipboard *dmmclip.Clipboard - menu *menu.Menu - layout *layout.Layout + menu *menu.Menu + layout *layout.Layout + extensions *extensions.Manager } func (a *app) initialize() { @@ -106,6 +111,7 @@ func (a *app) initialize() { a.loadConfig() a.loadProjectConfig() + a.extensions = extensions.New(a.projectConfig().ExtensionState, a.extensionsDir) a.loadPreferencesConfig() a.runBackgroundConfigSave() @@ -132,6 +138,9 @@ func (a *app) initialize() { } func (a *app) Process() { + if a.extensions != nil { + a.extensions.Process() + } if a.shortcutsEnabled { shortcut.Process() } @@ -164,6 +173,9 @@ func (a *app) LayoutIniPath() string { } func (a *app) dispose() { + if a.extensions != nil { + a.extensions.Dispose() + } brush.Dispose() a.configSave() a.masterWindow.Dispose() diff --git a/internal/app/config_project.go b/internal/app/config_project.go index 7f071b59..16c23828 100644 --- a/internal/app/config_project.go +++ b/internal/app/config_project.go @@ -4,6 +4,7 @@ import ( "errors" "os" + "sdmm/internal/app/extensions" "sdmm/internal/util/slice" "github.com/rs/zerolog/log" @@ -11,14 +12,15 @@ import ( const ( projectConfigName = "project" - projectConfigVersion = 2 + projectConfigVersion = 3 ) type projectConfig struct { Version uint - Projects []string - Maps []string + Projects []string + Maps []string + ExtensionState *extensions.State } func (projectConfig) Name() string { @@ -44,7 +46,11 @@ func (projectConfig) TryMigrate(cfg map[string]any) (result map[string]any, migr result["Version"] = 2 migrated = true } - + if uint(result["Version"].(float64)) == 2 { + result["ExtensionState"] = map[string]any{} + result["Version"] = 3 + migrated = true + } return result, migrated } @@ -79,10 +85,14 @@ func (cfg *projectConfig) RemoveMap(mapPath string) { func (a *app) loadProjectConfig() { config := &projectConfig{ - Version: projectConfigVersion, + Version: projectConfigVersion, + ExtensionState: &extensions.State{}, } a.ConfigRegister(config) + if config.ExtensionState == nil { + config.ExtensionState = &extensions.State{} + } // Cleanup projects paths var pathsToRemove []string diff --git a/internal/app/extensions/manager.go b/internal/app/extensions/manager.go new file mode 100644 index 00000000..4f5292ca --- /dev/null +++ b/internal/app/extensions/manager.go @@ -0,0 +1,615 @@ +package extensions + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "time" + + api "github.com/SpaiR/StrongDMM-extension-api" + "github.com/hashicorp/go-plugin" + "sdmm/internal/app/render" + "sdmm/internal/dmapi/dmmap" + "sdmm/internal/dmapi/dmvars" + "sdmm/internal/util" + + "github.com/rs/zerolog/log" +) + +type State struct{ Extensions map[string]*ExtensionState } +type ExtensionState struct { + Disabled bool + Preview bool + Settings map[string]bool +} +type MenuAction struct { + ID, Menu, Label string + Enabled, Selected bool +} +type ContextMenuAction struct { + ID, Label string + Enabled bool +} + +type Manager struct { + state *State + clients []*client + documents map[*dmmap.Dmm]*document +} + +type document struct { + id string + revision uint64 + opened bool + definitions map[string]uint32 + atoms map[uint32]api.AtomDefinition + nextDefinition uint32 + renderers map[*render.Render]struct{} + outputs map[*client]map[string]api.RenderPass + applied map[*client]uint64 + newDefinitions []api.AtomDefinition +} + +func New(state *State, extensionsDir string) *Manager { + if state.Extensions == nil { + state.Extensions = make(map[string]*ExtensionState) + } + m := &Manager{state: state, clients: discover(extensionsDir), documents: make(map[*dmmap.Dmm]*document)} + for _, client := range m.clients { + m.stateFor(client) + client.startWorker() + } + return m +} + +func (m *Manager) stateFor(client *client) *ExtensionState { + if state := m.state.Extensions[client.manifest.ID]; state != nil { + return state + } + state := &ExtensionState{Preview: true} + m.state.Extensions[client.manifest.ID] = state + return state +} +func (m *Manager) enabled(client *client) bool { return !m.stateFor(client).Disabled } +func (m *Manager) preview(client *client) bool { return m.stateFor(client).Preview } +func (m *Manager) settings(client *client) map[string]bool { + settings := make(map[string]bool) + for id, value := range m.stateFor(client).Settings { + settings[id] = value + } + return settings +} + +func (m *Manager) MenuActions() []MenuAction { + actions := make([]MenuAction, 0) + for _, client := range m.clients { + for _, action := range client.manifest.Actions { + selected, enabled := false, true + switch action.Kind { + case "toggle-enabled": + selected = m.enabled(client) + case "toggle-preview": + selected, enabled = m.preview(client), m.enabled(client) + case "toggle": + selected, enabled = m.stateFor(client).Settings[action.ID], m.enabled(client) + default: + continue + } + actions = append(actions, MenuAction{ID: client.manifest.ID + ":" + action.ID, Menu: action.Menu, Label: action.Label, Enabled: enabled, Selected: selected}) + } + } + return actions +} + +func (m *Manager) ExecuteMenuAction(id string) { + for _, client := range m.clients { + for _, action := range client.manifest.Actions { + if id != client.manifest.ID+":"+action.ID { + continue + } + state := m.stateFor(client) + switch action.Kind { + case "toggle-enabled": + state.Disabled = !state.Disabled + case "toggle-preview": + state.Preview = !state.Preview + case "toggle": + if state.Settings == nil { + state.Settings = make(map[string]bool) + } + state.Settings[action.ID] = !state.Settings[action.ID] + } + m.refreshClient(client) + return + } + } +} + +// ContextMenuActions returns dynamic actions contributed for one turf. +func (m *Manager) ContextMenuActions(dmm *dmmap.Dmm, point util.Point) []ContextMenuAction { + doc := m.document(dmm) + turf := m.contextTurf(dmm.GetTile(point)) + actions := make([]ContextMenuAction, 0) + for _, client := range m.clients { + if !m.enabled(client) || !client.hasCapability(api.CapabilityContextMenu) { + continue + } + response, err := client.extension.Handle(api.Request{Version: api.ProtocolVersion, Type: "context.menu", MapID: doc.id, Revision: doc.revision, Context: &api.ContextMenuRequest{Turf: turf}}) + if err != nil || response.ContextMenu == nil { + if err != nil { + log.Warn().Err(err).Str("extension", client.manifest.ID).Msg("extension context menu request failed") + } + continue + } + for _, action := range response.ContextMenu.Actions { + if action.ID == "" || action.Label == "" { + continue + } + actions = append(actions, ContextMenuAction{ID: client.manifest.ID + ":" + action.ID, Label: action.Label, Enabled: action.Enabled}) + } + } + return actions +} + +// ExecuteContextMenuAction notifies the extension that supplied an action. +func (m *Manager) ExecuteContextMenuAction(dmm *dmmap.Dmm, point util.Point, id string) { + doc := m.document(dmm) + for _, client := range m.clients { + prefix := client.manifest.ID + ":" + if !m.enabled(client) || !client.hasCapability(api.CapabilityContextMenu) || len(id) <= len(prefix) || id[:len(prefix)] != prefix { + continue + } + response, err := client.extension.Handle(api.Request{Version: api.ProtocolVersion, Type: "context.action", MapID: doc.id, Revision: doc.revision, Action: &api.ActionRequest{ID: id[len(prefix):], Turf: m.contextTurf(dmm.GetTile(point))}}) + if err != nil { + log.Warn().Err(err).Str("extension", client.manifest.ID).Msg("extension context action failed") + return + } + m.apply(client, workerResult{message: api.Request{Version: response.Version, Type: response.Type, MapID: response.MapID, Revision: response.Revision, Render: response.Render}}) + return + } +} + +func (m *Manager) ConfigureRender(renderer *render.Render, dmm *dmmap.Dmm) { + doc := m.document(dmm) + doc.renderers[renderer] = struct{}{} + renderer.SetExtensionPasses(m.extensionPasses(doc)) + if !doc.opened { + m.queueOpen(doc, dmm) + } +} + +func (m *Manager) RefreshRender(renderer *render.Render, dmm *dmmap.Dmm) { + m.ConfigureRender(renderer, dmm) + m.queueOpen(m.document(dmm), dmm) +} + +// ReleaseRender disconnects a canvas from its map's extension output. The +// extension receives map.close only after the last canvas using the map closes. +func (m *Manager) ReleaseRender(renderer *render.Render, dmm *dmmap.Dmm) { + doc := m.documents[dmm] + if doc == nil { + return + } + delete(doc.renderers, renderer) + if len(doc.renderers) != 0 { + return + } + m.closeDocument(dmm, doc) +} + +// ReplaceRender swaps a canvas renderer without interrupting the extension's +// map session, for example while recreating a canvas after a resize. +func (m *Manager) ReplaceRender(previous, next *render.Render, dmm *dmmap.Dmm) { + doc := m.documents[dmm] + if doc == nil { + m.ConfigureRender(next, dmm) + return + } + delete(doc.renderers, previous) + doc.renderers[next] = struct{}{} + next.SetExtensionPasses(m.extensionPasses(doc)) +} + +func (m *Manager) NotifyChanged(dmm *dmmap.Dmm, points []util.Point) { + doc := m.document(dmm) + doc.revision++ + update := m.update(doc, dmm, points) + for _, client := range m.clients { + if m.enabled(client) && m.preview(client) && client.usesMap() { + client.queue(work{message: api.Request{Version: api.ProtocolVersion, Type: "map.update", MapID: doc.id, Revision: doc.revision, Update: &update}}) + } + } +} + +func (m *Manager) NotifyResize(dmm *dmmap.Dmm) { + doc := m.document(dmm) + doc.revision++ + m.queueOpen(doc, dmm) +} + +func (m *Manager) Process() { + for _, client := range m.clients { + for { + select { + case result := <-client.results: + m.apply(client, result) + default: + goto next + } + } + next: + } +} + +func (m *Manager) closeDocument(dmm *dmmap.Dmm, doc *document) { + for _, client := range m.clients { + client.queue(work{message: api.Request{Version: api.ProtocolVersion, Type: "map.close", MapID: doc.id, Revision: doc.revision}}) + } + delete(m.documents, dmm) +} +func (m *Manager) Dispose() { + for _, client := range m.clients { + client.close() + } +} + +func (m *Manager) document(dmm *dmmap.Dmm) *document { + if doc := m.documents[dmm]; doc != nil { + return doc + } + doc := &document{id: fmt.Sprintf("map-%p", dmm), definitions: make(map[string]uint32), atoms: make(map[uint32]api.AtomDefinition), nextDefinition: 1, renderers: make(map[*render.Render]struct{}), outputs: make(map[*client]map[string]api.RenderPass), applied: make(map[*client]uint64)} + m.documents[dmm] = doc + return doc +} + +func (m *Manager) queueOpen(doc *document, dmm *dmmap.Dmm) { + if doc.revision == 0 { + doc.revision = 1 + } + doc.opened = true + mapData := m.mapData(doc, dmm, nil) + for _, client := range m.clients { + if m.enabled(client) && m.preview(client) && client.usesMap() { + client.queue(work{immediate: true, message: api.Request{Version: api.ProtocolVersion, Type: "map.open", MapID: doc.id, Revision: doc.revision, Settings: m.settings(client), Map: &mapData}}) + } + } +} + +func (m *Manager) refreshClient(client *client) { + for dmm, doc := range m.documents { + if !m.enabled(client) || !m.preview(client) || !client.usesMap() { + delete(doc.outputs, client) + m.render(doc) + continue + } + mapData := m.mapData(doc, dmm, nil) + client.queue(work{immediate: true, message: api.Request{Version: api.ProtocolVersion, Type: "map.open", MapID: doc.id, Revision: doc.revision, Settings: m.settings(client), Map: &mapData}}) + } +} + +func (m *Manager) apply(client *client, result workerResult) { + var doc *document + for _, candidate := range m.documents { + if candidate.id == result.message.MapID { + doc = candidate + break + } + } + if doc == nil { + return + } + if result.err != nil { + log.Error().Err(result.err).Str("extension", client.manifest.ID).Msg("extension request failed") + return + } + message := result.message + if message.Type == "resync.request" { + for dmm, candidate := range m.documents { + if candidate == doc { + m.queueOpen(doc, dmm) + return + } + } + } + if message.Type != "render.update" || message.Render == nil || message.Revision < doc.applied[client] { + return + } + passes := doc.outputs[client] + if passes == nil { + passes = make(map[string]api.RenderPass) + doc.outputs[client] = passes + } + if message.Render.Replace != nil { + passes = make(map[string]api.RenderPass) + for _, pass := range message.Render.Replace { + passes[pass.ID] = pass + } + doc.outputs[client] = passes + } + for _, passID := range message.Render.RemovePasses { + delete(passes, passID) + } + for _, patch := range message.Render.Patches { + pass, ok := passes[patch.PassID] + if !ok { + continue + } + commands := make(map[string]api.RenderCommand) + for _, command := range pass.Commands { + commands[command.ID] = command + } + for _, id := range patch.Remove { + delete(commands, id) + } + for _, command := range patch.Upsert { + commands[command.ID] = command + } + pass.Commands = pass.Commands[:0] + for _, command := range commands { + pass.Commands = append(pass.Commands, command) + } + sort.Slice(pass.Commands, func(i, j int) bool { return pass.Commands[i].ID < pass.Commands[j].ID }) + passes[pass.ID] = pass + } + doc.applied[client] = message.Revision + m.render(doc) +} + +func (m *Manager) render(doc *document) { + passes := m.extensionPasses(doc) + for renderer := range doc.renderers { + renderer.SetExtensionPasses(passes) + } +} + +// extensionPasses produces a stable global pass list. Client discovery is +// already sorted by extension ID; sorting each client's pass IDs keeps output +// stable even though they are stored in a map. +func (m *Manager) extensionPasses(doc *document) []api.RenderPass { + passes := make([]api.RenderPass, 0) + seen := make(map[string]struct{}) + for _, client := range m.clients { + clientPasses := doc.outputs[client] + ids := make([]string, 0, len(clientPasses)) + for id := range clientPasses { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + pass := clientPasses[id] + if _, duplicate := seen[pass.ID]; duplicate { + log.Warn().Str("extension", client.manifest.ID).Str("pass", pass.ID).Msg("ignoring duplicate extension render pass") + continue + } + seen[pass.ID] = struct{}{} + passes = append(passes, pass) + } + } + return passes +} + +func (m *Manager) mapData(doc *document, dmm *dmmap.Dmm, points []util.Point) api.Map { + mapData := api.Map{Width: dmm.MaxX, Height: dmm.MaxY, Levels: dmm.MaxZ} + full := points == nil + doc.newDefinitions = nil + if points == nil { + points = make([]util.Point, 0, len(dmm.Tiles)) + for _, tile := range dmm.Tiles { + points = append(points, tile.Coord) + } + } + for _, point := range points { + mapData.Tiles = append(mapData.Tiles, m.tile(doc, dmm.GetTile(point))) + } + if full { + for _, atom := range doc.atoms { + mapData.Definitions = append(mapData.Definitions, atom) + } + sort.Slice(mapData.Definitions, func(i, j int) bool { return mapData.Definitions[i].ID < mapData.Definitions[j].ID }) + } else { + mapData.Definitions = doc.newDefinitions + } + return mapData +} +func (m *Manager) update(doc *document, dmm *dmmap.Dmm, points []util.Point) api.MapUpdate { + mapData := m.mapData(doc, dmm, points) + return api.MapUpdate{Width: mapData.Width, Height: mapData.Height, Levels: mapData.Levels, Definitions: mapData.Definitions, Tiles: mapData.Tiles} +} +func (m *Manager) tile(doc *document, tile *dmmap.Tile) api.Tile { + out := api.Tile{X: tile.Coord.X, Y: tile.Coord.Y, Z: tile.Coord.Z} + for _, instance := range tile.Instances() { + vars := make(map[string]string) + prefabVars := instance.Prefab().Vars() + keys := resolvedVariableNames(prefabVars) + sort.Strings(keys) + for _, key := range keys { + if value, ok := prefabVars.Value(key); ok { + vars[key] = value + } + } + key := instance.Prefab().Path() + for _, name := range keys { + key += "\x00" + name + "=" + vars[name] + } + id := doc.definitions[key] + if id == 0 { + id = doc.nextDefinition + doc.nextDefinition++ + doc.definitions[key] = id + atom := api.AtomDefinition{ID: id, Path: instance.Prefab().Path(), Vars: vars} + doc.atoms[id] = atom + doc.newDefinitions = append(doc.newDefinitions, atom) + out.Atoms = append(out.Atoms, id) + continue + } + out.Atoms = append(out.Atoms, id) + } + return out +} +func (m *Manager) contextTurf(tile *dmmap.Tile) api.ContextTurf { + turf := api.ContextTurf{X: tile.Coord.X, Y: tile.Coord.Y, Z: tile.Coord.Z} + for _, instance := range tile.Instances() { + vars := make(map[string]string) + prefabVars := instance.Prefab().Vars() + for _, key := range resolvedVariableNames(prefabVars) { + if value, ok := prefabVars.Value(key); ok { + vars[key] = value + } + } + turf.Atoms = append(turf.Atoms, api.AtomDefinition{Path: instance.Prefab().Path(), Vars: vars}) + } + return turf +} + +func resolvedVariableNames(vars *dmvars.Variables) []string { + names := make(map[string]struct{}) + for current := vars; current != nil; current = current.Parent() { + for _, name := range current.Iterate() { + names[name] = struct{}{} + } + } + keys := make([]string, 0, len(names)) + for name := range names { + keys = append(keys, name) + } + return keys +} + +type manifest struct { + ID string `json:"id"` + Name string `json:"name"` + APIVersion int `json:"apiVersion"` + Commands map[string]string `json:"commands"` + Capabilities []string `json:"capabilities"` + Actions []manifestAction `json:"actions"` +} +type manifestAction struct{ ID, Menu, Label, Kind string } +type work struct { + message api.Request + immediate bool +} +type workerResult struct { + message api.Request + err error +} +type client struct { + manifest manifest + process *plugin.Client + extension api.Extension + jobs chan work + results chan workerResult + stop chan struct{} +} + +func (c *client) hasCapability(capability string) bool { + for _, candidate := range c.manifest.Capabilities { + if candidate == capability { + return true + } + } + return false +} +func (c *client) usesMap() bool { + return c.hasCapability(api.CapabilityRender) || c.hasCapability(api.CapabilityContextMenu) +} + +func (c *client) startWorker() { + c.jobs = make(chan work, 128) + c.results = make(chan workerResult, 128) + c.stop = make(chan struct{}) + go c.run() +} +func (c *client) queue(work work) { + select { + case c.jobs <- work: + default: + log.Warn().Str("extension", c.manifest.ID).Msg("extension update queue full") + } +} +func (c *client) close() { + select { + case <-c.stop: + default: + close(c.stop) + } + if c.process != nil { + c.process.Kill() + } +} +func (c *client) run() { + pending := make(map[string]work) + timer := time.NewTimer(time.Hour) + if !timer.Stop() { + <-timer.C + } + for { + select { + case <-c.stop: + return + case work := <-c.jobs: + if work.immediate || work.message.Type == "map.close" { + c.execute(work) + continue + } + pending[work.message.MapID] = merge(pending[work.message.MapID], work) + timer.Reset(50 * time.Millisecond) + case <-timer.C: + for id, work := range pending { + delete(pending, id) + c.execute(work) + } + } + } +} +func merge(previous, next work) work { + if previous.message.Type == "" { + return next + } + if previous.message.Type != "map.update" || next.message.Type != "map.update" { + return next + } + tiles := make(map[string]api.Tile) + for _, tile := range previous.message.Update.Tiles { + tiles[fmt.Sprintf("%d/%d/%d", tile.X, tile.Y, tile.Z)] = tile + } + for _, tile := range next.message.Update.Tiles { + tiles[fmt.Sprintf("%d/%d/%d", tile.X, tile.Y, tile.Z)] = tile + } + next.message.Update.Tiles = next.message.Update.Tiles[:0] + for _, tile := range tiles { + next.message.Update.Tiles = append(next.message.Update.Tiles, tile) + } + next.message.Update.Definitions = append(previous.message.Update.Definitions, next.message.Update.Definitions...) + return next +} +func (c *client) execute(work work) { + message, err := c.request(work.message) + if err != nil { + c.results <- workerResult{message: work.message, err: err} + return + } + c.results <- workerResult{message: message} +} +func (c *client) request(message api.Request) (api.Request, error) { + response, err := c.extension.Handle(message) + return api.Request{Version: response.Version, Type: response.Type, MapID: response.MapID, Revision: response.Revision, Render: response.Render, Reason: response.Reason}, err +} + +func discover(root string) []*client { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + clients := make([]*client, 0) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".sdmmext" { + continue + } + if client := newPackageClient(filepath.Join(root, entry.Name()), filepath.Join(root, ".cache")); client != nil { + clients = append(clients, client) + } + } + sort.Slice(clients, func(i, j int) bool { return clients[i].manifest.ID < clients[j].manifest.ID }) + return clients +} diff --git a/internal/app/extensions/package.go b/internal/app/extensions/package.go new file mode 100644 index 00000000..a6ea354f --- /dev/null +++ b/internal/app/extensions/package.go @@ -0,0 +1,148 @@ +package extensions + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + api "github.com/SpaiR/StrongDMM-extension-api" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/go-plugin" + "github.com/rs/zerolog/log" +) + +// newPackageClient opens a .sdmmext ZIP package, extracts it to a content- +// addressed cache directory, and starts its command for this platform. +func newPackageClient(packagePath, cacheRoot string) *client { + hash, err := fileHash(packagePath) + if err != nil { + log.Warn().Err(err).Str("package", packagePath).Msg("unable to read extension package") + return nil + } + dir := filepath.Join(cacheRoot, hash) + manifestPath := filepath.Join(dir, "extension.json") + if _, err = os.Stat(manifestPath); err != nil { + if err = extractPackage(packagePath, dir); err != nil { + log.Warn().Err(err).Str("package", packagePath).Msg("unable to extract extension package") + return nil + } + } + data, err := os.ReadFile(manifestPath) + if err != nil { + log.Warn().Err(err).Str("package", packagePath).Msg("unable to read extension manifest") + return nil + } + var manifest manifest + if json.Unmarshal(data, &manifest) != nil || manifest.ID == "" || manifest.APIVersion != api.ProtocolVersion { + log.Warn().Str("package", packagePath).Msg("extension manifest is invalid or incompatible") + return nil + } + commandPath := manifest.Commands[runtime.GOOS+"-"+runtime.GOARCH] + if commandPath == "" || filepath.IsAbs(commandPath) || filepath.Dir(commandPath) == "." { + log.Warn().Str("package", packagePath).Msg("extension has no command for this platform") + return nil + } + binary := filepath.Join(dir, commandPath) + if relative, err := filepath.Rel(dir, binary); err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + log.Warn().Str("package", packagePath).Msg("extension command path escapes its package") + return nil + } + if _, err = os.Stat(binary); err != nil { + log.Warn().Err(err).Str("package", packagePath).Msg("extension command is missing") + return nil + } + process := plugin.NewClient(&plugin.ClientConfig{ + HandshakeConfig: api.HandshakeConfig, + Plugins: plugin.PluginSet{api.PluginName: &api.Plugin{}}, + Cmd: exec.Command(binary), + AllowedProtocols: []plugin.Protocol{plugin.ProtocolNetRPC}, + Logger: hclog.New(&hclog.LoggerOptions{Name: manifest.ID, Output: os.Stderr}), + }) + rpcClient, err := process.Client() + if err != nil { + log.Warn().Err(err).Str("extension", manifest.ID).Msg("unable to start extension") + process.Kill() + return nil + } + raw, err := rpcClient.Dispense(api.PluginName) + if err != nil { + log.Warn().Err(err).Str("extension", manifest.ID).Msg("extension did not provide its service") + process.Kill() + return nil + } + extension, ok := raw.(api.Extension) + if !ok { + log.Warn().Str("extension", manifest.ID).Msg("extension returned an incompatible service") + process.Kill() + return nil + } + log.Info().Str("extension", manifest.ID).Str("package", packagePath).Msg("extension loaded") + return &client{manifest: manifest, process: process, extension: extension} +} + +func fileHash(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err = io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func extractPackage(packagePath, destination string) error { + archive, err := zip.OpenReader(packagePath) + if err != nil { + return err + } + defer archive.Close() + for _, entry := range archive.File { + entryPath := filepath.FromSlash(strings.ReplaceAll(entry.Name, "\\", "/")) + if filepath.IsAbs(entryPath) { + continue + } + path := filepath.Join(destination, entryPath) + relative, relErr := filepath.Rel(destination, path) + if relErr != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + continue + } + if entry.FileInfo().IsDir() { + if err = os.MkdirAll(path, os.ModePerm); err != nil { + return err + } + continue + } + if err = os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return err + } + input, err := entry.Open() + if err != nil { + return err + } + output, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, entry.Mode()) + if err != nil { + input.Close() + return err + } + _, copyErr := io.Copy(output, input) + closeErr := output.Close() + input.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + } + return nil +} diff --git a/internal/app/render/brush/batch.go b/internal/app/render/brush/batch.go index e2ae974c..02380aab 100644 --- a/internal/app/render/brush/batch.go +++ b/internal/app/render/brush/batch.go @@ -21,6 +21,9 @@ type Batching struct { offset int } +// Vertex is the internal representation of generic untextured mesh data. +type Vertex struct{ X, Y, R, G, B, A float32 } + func (b *Batching) flush() { if b.len != 0 && len(b.indices) > 0 { b.calls = append(b.calls, batchCall{ diff --git a/internal/app/render/brush/bilinear.go b/internal/app/render/brush/bilinear.go new file mode 100644 index 00000000..06f65674 --- /dev/null +++ b/internal/app/render/brush/bilinear.go @@ -0,0 +1,47 @@ +package brush + +import ( + "sdmm/internal/platform" + + "github.com/go-gl/gl/v3.3-core/gl" +) + +type Color struct{ R, G, B float32 } + +type BilinearQuad struct { + X1, Y1, X2, Y2 float32 + SouthWest, SouthEast, NorthWest, NorthEast Color +} + +func BilinearQuads(quads []BilinearQuad, w, h, x, y, z float32) { + if len(quads) == 0 { + return + } + data := make([]float32, 0, len(quads)*4*16) + indices := make([]uint32, 0, len(quads)*6) + for quadIndex, quad := range quads { + base := uint32(quadIndex * 4) + for _, vertex := range [][4]float32{{quad.X1, quad.Y1, 0, 0}, {quad.X2, quad.Y1, 1, 0}, {quad.X1, quad.Y2, 0, 1}, {quad.X2, quad.Y2, 1, 1}} { + data = append(data, vertex[0], vertex[1], + quad.SouthWest.R, quad.SouthWest.G, quad.SouthWest.B, + quad.SouthEast.R, quad.SouthEast.G, quad.SouthEast.B, + quad.NorthWest.R, quad.NorthWest.G, quad.NorthWest.B, + quad.NorthEast.R, quad.NorthEast.G, quad.NorthEast.B, + vertex[2], vertex[3]) + } + indices = append(indices, base, base+1, base+2, base+1, base+3, base+2) + } + gl.UseProgram(bilinearProgram) + gl.BindVertexArray(bilinearVAO) + mtx := transformationMatrix(w, h, x, y, z) + gl.UniformMatrix4fv(bilinearTransform, 1, false, &mtx[0]) + gl.BindBuffer(gl.ARRAY_BUFFER, bilinearVBO) + gl.BufferData(gl.ARRAY_BUFFER, len(data)*platform.FloatSize, gl.Ptr(data), gl.STREAM_DRAW) + gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, bilinearEBO) + gl.BufferData(gl.ELEMENT_ARRAY_BUFFER, len(indices)*4, gl.Ptr(indices), gl.STREAM_DRAW) + gl.DrawElements(gl.TRIANGLES, int32(len(indices)), gl.UNSIGNED_INT, gl.PtrOffset(0)) + gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, 0) + gl.BindBuffer(gl.ARRAY_BUFFER, 0) + gl.BindVertexArray(0) + gl.UseProgram(0) +} diff --git a/internal/app/render/brush/brush.go b/internal/app/render/brush/brush.go index 85e1865c..019c1b34 100644 --- a/internal/app/render/brush/brush.go +++ b/internal/app/render/brush/brush.go @@ -12,7 +12,8 @@ var ( attrsList attributesList - program uint32 + program uint32 + bilinearProgram uint32 vao uint32 vbo uint32 @@ -20,6 +21,10 @@ var ( uniformLocationTransform int32 uniformLocationHasTexture int32 + bilinearTransform int32 + bilinearVAO uint32 + bilinearVBO uint32 + bilinearEBO uint32 ) func TryInit() { @@ -50,6 +55,7 @@ func TryInit() { initShader(vertexShader(), fragmentShader()) initBuffers() initAttributes() + initBilinear() log.Print("initialized") } @@ -60,9 +66,36 @@ func Dispose() { gl.DeleteVertexArrays(1, &vao) gl.DeleteBuffers(1, &vbo) gl.DeleteBuffers(1, &ebo) + gl.DeleteVertexArrays(1, &bilinearVAO) + gl.DeleteBuffers(1, &bilinearVBO) + gl.DeleteBuffers(1, &bilinearEBO) log.Print("disposed") } +func initBilinear() { + var err error + if bilinearProgram, err = platform.NewShaderProgram(bilinearVertexShader(), bilinearFragmentShader()); err != nil { + log.Fatal().Msgf("unable to create bilinear shader: %v", err) + } + bilinearTransform = gl.GetUniformLocation(bilinearProgram, gl.Str("Transform\x00")) + gl.GenVertexArrays(1, &bilinearVAO) + gl.GenBuffers(1, &bilinearVBO) + gl.GenBuffers(1, &bilinearEBO) + gl.BindVertexArray(bilinearVAO) + gl.BindBuffer(gl.ARRAY_BUFFER, bilinearVBO) + const stride = 16 * platform.FloatSize + for index, offset := range []int32{0, 2, 5, 8, 11, 14} { + size := int32(3) + if index == 0 || index == 5 { + size = 2 + } + gl.EnableVertexAttribArray(uint32(index)) + gl.VertexAttribPointerWithOffset(uint32(index), size, gl.FLOAT, false, stride, uintptr(offset*platform.FloatSize)) + } + gl.BindBuffer(gl.ARRAY_BUFFER, 0) + gl.BindVertexArray(0) +} + func initShader(vertex, fragment string) { log.Print("initializing shader...") var err error diff --git a/internal/app/render/brush/shader.go b/internal/app/render/brush/shader.go index 48f112f8..169cea07 100644 --- a/internal/app/render/brush/shader.go +++ b/internal/app/render/brush/shader.go @@ -42,3 +42,46 @@ void main() { } ` + "\x00" } + +func bilinearVertexShader() string { + return ` +#version 330 core +uniform mat4 Transform; +layout (location = 0) in vec2 in_pos; +layout (location = 1) in vec3 in_south_west; +layout (location = 2) in vec3 in_south_east; +layout (location = 3) in vec3 in_north_west; +layout (location = 4) in vec3 in_north_east; +layout (location = 5) in vec2 in_uv; +flat out vec3 south_west; +flat out vec3 south_east; +flat out vec3 north_west; +flat out vec3 north_east; +out vec2 uv; +void main() { + south_west = in_south_west; + south_east = in_south_east; + north_west = in_north_west; + north_east = in_north_east; + uv = in_uv; + gl_Position = Transform * vec4(in_pos, 1, 1); +} +` + "\x00" +} + +func bilinearFragmentShader() string { + return ` +#version 330 core +flat in vec3 south_west; +flat in vec3 south_east; +flat in vec3 north_west; +flat in vec3 north_east; +in vec2 uv; +out vec4 outputColor; +void main() { + vec3 south = mix(south_west, south_east, uv.x); + vec3 north = mix(north_west, north_east, uv.x); + outputColor = vec4(mix(south, north, uv.y), 1.0); +} +` + "\x00" +} diff --git a/internal/app/render/brush/shape.go b/internal/app/render/brush/shape.go index 1a05be55..1548976f 100644 --- a/internal/app/render/brush/shape.go +++ b/internal/app/render/brush/shape.go @@ -50,6 +50,29 @@ func RectFilledV(x1, y1, x2, y2, r, g, b, a float32) { RectTexturedV(x1, y1, x2, y2, r, g, b, a, s.Texture(), s.U1, s.V1, s.U2, s.V2) } +// ColoredTriangles appends a vertex-coloured triangle mesh. +func ColoredTriangles(vertices []Vertex, indices []uint32) { + if len(vertices) == 0 || len(indices) == 0 { + return + } + s := dmicon.WhiteRect() + if batching.mode != mtRect || batching.texture != s.Texture() { + batching.flush() + } + batching.texture, batching.mode = s.Texture(), mtRect + for _, vertex := range vertices { + batching.data = append(batching.data, vertex.X, vertex.Y, vertex.R, vertex.G, vertex.B, vertex.A, s.U1, s.V1) + } + for _, index := range indices { + if index >= uint32(len(vertices)) { + continue + } + batching.indices = append(batching.indices, batching.idx+index) + batching.len++ + } + batching.idx += uint32(len(vertices)) +} + func Rect(x1, y1, x2, y2 float32, col util.Color) { RectV(x1, y1, x2, y2, col.R(), col.G(), col.B(), col.A()) } diff --git a/internal/app/render/bucket.go b/internal/app/render/bucket.go index 63553890..1759fc2d 100644 --- a/internal/app/render/bucket.go +++ b/internal/app/render/bucket.go @@ -19,21 +19,41 @@ type unitProcessor interface { func (r *Render) batchBucketUnits(viewBounds util.Bounds) { if MultiZRendering && r.Camera.Level > 1 { for level := 1; level < r.Camera.Level; level++ { - r.batchLevel(level, viewBounds, false) // Draw everything below. + r.batchLevel(level, viewBounds) // Draw everything below. } // Draw a "shadow" overlay to visually separate different levels. brush.RectFilled(viewBounds.X1, viewBounds.Y1, viewBounds.X2, viewBounds.Y2, multiZShadow) } - r.batchLevel(r.Camera.Level, viewBounds, true) // Draw currently visible level. + r.batchLevel(r.Camera.Level, viewBounds) // Draw currently visible level. +} - if r.overlay != nil { - r.overlay.FlushUnits() +// batchUnitHighlights batches the active level's selected-unit highlights. +func (r *Render) batchUnitHighlights(viewBounds util.Bounds) { + if r.overlay == nil { + return + } + visibleLevel := r.bucket.Level(r.Camera.Level) + if visibleLevel == nil { + return } + for _, layer := range visibleLevel.Layers { + for _, chunk := range visibleLevel.ChunksByLayers[layer] { + if !chunk.ViewBounds.ContainsV(viewBounds) { + continue + } + for _, u := range chunk.UnitsByLayers[layer] { + if u.ViewBounds().ContainsV(viewBounds) { + r.batchUnitHighlight(u) + } + } + } + } + r.overlay.FlushUnits() } -func (r *Render) batchLevel(level int, viewBounds util.Bounds, withUnitHighlight bool) { +func (r *Render) batchLevel(level int, viewBounds util.Bounds) { visibleLevel := r.bucket.Level(level) if visibleLevel == nil { return @@ -66,9 +86,6 @@ func (r *Render) batchLevel(level int, viewBounds util.Bounds, withUnitHighlight u.Sprite().U1, u.Sprite().V1, u.Sprite().U2, u.Sprite().V2, ) - if withUnitHighlight { - r.batchUnitHighlight(u) - } } } } diff --git a/internal/app/render/extension_passes.go b/internal/app/render/extension_passes.go new file mode 100644 index 00000000..8621d3ef --- /dev/null +++ b/internal/app/render/extension_passes.go @@ -0,0 +1,144 @@ +package render + +import ( + "sort" + + api "github.com/SpaiR/StrongDMM-extension-api" + "sdmm/internal/app/render/brush" + + "github.com/rs/zerolog/log" +) + +const ( + coreMap = api.StageMap + coreSelection = api.StageSelection + coreOverlays = api.StageOverlays +) + +type graphNode struct { + id string + pass *api.RenderPass +} + +// SetExtensionPasses compiles extension pass declarations into the same graph +// as StrongDMM's core render stages. +func (r *Render) SetExtensionPasses(passes []api.RenderPass) { + r.renderGraph = compileRenderGraph(passes) +} + +func compileRenderGraph(passes []api.RenderPass) []graphNode { + nodes := []graphNode{{id: coreMap}, {id: coreSelection}, {id: coreOverlays}} + byID := map[string]int{coreMap: 0, coreSelection: 1, coreOverlays: 2} + passes = append([]api.RenderPass(nil), passes...) + sort.SliceStable(passes, func(i, j int) bool { return passes[i].ID < passes[j].ID }) + for i := range passes { + if passes[i].ID == "" { + log.Warn().Msg("ignoring extension render pass without an ID") + continue + } + if _, exists := byID[passes[i].ID]; exists { + log.Warn().Str("pass", passes[i].ID).Msg("ignoring duplicate extension render pass") + continue + } + byID[passes[i].ID] = len(nodes) + nodes = append(nodes, graphNode{id: passes[i].ID, pass: &passes[i]}) + } + deps := make([]int, len(nodes)) + forward := make([][]int, len(nodes)) + addEdge := func(from, to int) { + for _, next := range forward[from] { + if next == to { + return + } + } + forward[from] = append(forward[from], to) + deps[to]++ + } + addEdge(byID[coreMap], byID[coreSelection]) + addEdge(byID[coreSelection], byID[coreOverlays]) + for i, node := range nodes { + if node.pass == nil { + continue + } + pass := node.pass + validConstraint := false + for _, id := range pass.After { + if before, ok := byID[id]; ok { + addEdge(before, i) + validConstraint = true + } else { + log.Warn().Str("pass", pass.ID).Str("after", id).Msg("unknown extension render pass dependency") + } + } + for _, id := range pass.Before { + if after, ok := byID[id]; ok { + addEdge(i, after) + validConstraint = true + } else { + log.Warn().Str("pass", pass.ID).Str("before", id).Msg("unknown extension render pass dependency") + } + } + if !validConstraint { + addEdge(byID[coreMap], i) + addEdge(i, byID[coreSelection]) + } + } + ordered := make([]graphNode, 0, len(nodes)) + for len(ordered) < len(nodes) { + found := -1 + for i := range nodes { + if deps[i] == 0 { + found = i + break + } + } + if found < 0 { + log.Warn().Msg("extension render pass graph contains a cycle; using fallback order") + return fallbackRenderGraph(nodes) + } + deps[found] = -1 + ordered = append(ordered, nodes[found]) + for _, next := range forward[found] { + deps[next]-- + } + } + return ordered +} + +func fallbackRenderGraph(nodes []graphNode) []graphNode { + ordered := []graphNode{nodes[0]} + for _, node := range nodes[3:] { + ordered = append(ordered, node) + } + return append(ordered, nodes[1], nodes[2]) +} + +func (r *Render) drawExtensionPass(pass api.RenderPass, width, height float32) { + switch pass.Blend { + case "multiply": + r.setMultiplyBlend() + case "add": + r.setAddBlend() + default: + r.setNormalBlend() + } + for _, command := range pass.Commands { + switch command.Kind { + case "mesh": + vertices := make([]brush.Vertex, 0, len(command.Mesh.Vertices)) + for _, v := range command.Mesh.Vertices { + vertices = append(vertices, brush.Vertex{X: v.X, Y: v.Y, R: v.R, G: v.G, B: v.B, A: v.A}) + } + brush.ColoredTriangles(vertices, command.Mesh.Indices) + case "bilinear": + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + quads := make([]brush.BilinearQuad, 0, len(command.Mesh.Quads)) + for _, quad := range command.Mesh.Quads { + quads = append(quads, brush.BilinearQuad{X1: quad.X1, Y1: quad.Y1, X2: quad.X2, Y2: quad.Y2, SouthWest: brush.Color(quad.SouthWest), SouthEast: brush.Color(quad.SouthEast), NorthWest: brush.Color(quad.NorthWest), NorthEast: brush.Color(quad.NorthEast)}) + } + brush.BilinearQuads(quads, width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + } + } + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + r.setNormalBlend() +} diff --git a/internal/app/render/render.go b/internal/app/render/render.go index ab17d590..e71fa5fc 100644 --- a/internal/app/render/render.go +++ b/internal/app/render/render.go @@ -16,13 +16,15 @@ type Render struct { overlay overlay unitProcessor unitProcessor + renderGraph []graphNode } func New() *Render { brush.TryInit() return &Render{ - Camera: newCamera(), - bucket: bucket.New(), + Camera: newCamera(), + bucket: bucket.New(), + renderGraph: compileRenderGraph(nil), } } @@ -65,12 +67,31 @@ func (r *Render) prepare() { gl.ActiveTexture(gl.TEXTURE0) } +func (r *Render) setNormalBlend() { gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) } +func (r *Render) setMultiplyBlend() { gl.BlendFunc(gl.DST_COLOR, gl.ZERO) } +func (r *Render) setAddBlend() { gl.BlendFunc(gl.SRC_ALPHA, gl.ONE) } + func (r *Render) draw(width, height float32) { - r.batchBucketUnits(r.viewportBounds(width, height)) - //r.batchChunksVisuals() - r.batchOverlayAreasBorders() - r.batchOverlayAreas() - brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + view := r.viewportBounds(width, height) + for _, node := range r.renderGraph { + switch node.id { + case coreMap: + r.batchBucketUnits(view) + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + case coreSelection: + r.batchUnitHighlights(view) + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + case coreOverlays: + //r.batchChunksVisuals() + r.batchOverlayAreasBorders() + r.batchOverlayAreas() + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + default: + if node.pass != nil { + r.drawExtensionPass(*node.pass, width, height) + } + } + } } // Clean OpenGL state after rendering. diff --git a/internal/app/ui/cpwsarea/wsarea.go b/internal/app/ui/cpwsarea/wsarea.go index 84456aad..f47f98c1 100644 --- a/internal/app/ui/cpwsarea/wsarea.go +++ b/internal/app/ui/cpwsarea/wsarea.go @@ -117,6 +117,15 @@ func (w *WsArea) OpenMap(dmm *dmmap.Dmm, ws *workspace.Workspace) bool { return true } +// RefreshExtensions asks every open map pane to rebuild its extension layers. +func (w *WsArea) RefreshExtensions() { + for _, ws := range w.workspaces { + if mapWs, ok := ws.Content().(*wsmap.WsMap); ok { + mapWs.Map().RefreshExtensions() + } + } +} + func (w *WsArea) Close() { if w.activeWs != nil { w.closeWorkspaceGently(w.activeWs) diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/editor/commit.go b/internal/app/ui/cpwsarea/wsmap/pmap/editor/commit.go index c7b45e16..30191be1 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/editor/commit.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/editor/commit.go @@ -29,6 +29,7 @@ func (e *Editor) onMapSizeChange(maxZ int) { } e.pMap.Snapshot().Sync() // Do a full snapshots sync. e.pMap.OnMapSizeChange() + e.pMap.ResetExtensions() e.updateAreasZones() } @@ -75,5 +76,6 @@ func (e *Editor) commitChanges(commitMsg string) { func (e *Editor) updateBucket(activeLevel int, tilesToUpdate []util.Point) { window.RunLater(func() { e.pMap.Canvas().Render().UpdateBucketV(e.dmm, activeLevel, tilesToUpdate) + e.pMap.NotifyExtensions(tilesToUpdate) }) } diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go b/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go index ca87389c..1966329f 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go @@ -86,6 +86,8 @@ type attachedMap interface { PushAreaHover(bounds util.Bounds, fillColor, borderColor util.Color) OnMapSizeChange() + NotifyExtensions([]util.Point) + ResetExtensions() } func New(app app, attachedMap attachedMap, dmm *dmmap.Dmm) *Editor { diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go b/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go index 4798b1a4..a2e6b746 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go @@ -2,6 +2,7 @@ package pmap import ( "sdmm/internal/app/command" + "sdmm/internal/app/extensions" "sdmm/internal/app/prefs" "sdmm/internal/app/render" "sdmm/internal/app/ui/cpwsarea/wsmap/pmap/canvas" @@ -19,6 +20,7 @@ import ( "sdmm/internal/dmapi/dmmclip" "sdmm/internal/dmapi/dmmsnap" "sdmm/internal/imguiext/style" + "sdmm/internal/util" "github.com/SpaiR/imgui-go" "github.com/rs/zerolog/log" @@ -30,6 +32,7 @@ type App interface { psettings.App Prefs() prefs.Prefs + Extensions() *extensions.Manager LoadedEnvironment() *dmenv.Dme @@ -187,6 +190,9 @@ func New(app App, dmm *dmmap.Dmm) *PaneMap { p.canvas.Render().SetOverlay(p.canvasOverlay) p.canvas.Render().SetUnitProcessor(p) + if manager := app.Extensions(); manager != nil { + manager.ConfigureRender(p.canvas.Render(), dmm) + } p.canvas.Render().UpdateBucket(p.dmm, p.activeLevel) p.mouseChangeCbId = app.AddMouseChangeCallback(p.mouseChangeCallback) @@ -238,6 +244,9 @@ func (p *PaneMap) Process() { } func (p *PaneMap) Dispose() { + if manager := p.app.Extensions(); manager != nil { + manager.ReleaseRender(p.canvas.Render(), p.dmm) + } if p == lastActivePane { lastActivePane = nil } @@ -252,6 +261,27 @@ func (p *PaneMap) Dispose() { log.Print("disposed") } +// RefreshExtensions updates this map after extension settings or map contents +// change. It is intentionally small so other extension render layers can use +// the same host hook later. +func (p *PaneMap) RefreshExtensions() { + if manager := p.app.Extensions(); manager != nil { + manager.RefreshRender(p.canvas.Render(), p.dmm) + } +} + +func (p *PaneMap) NotifyExtensions(points []util.Point) { + if manager := p.app.Extensions(); manager != nil { + manager.NotifyChanged(p.dmm, points) + } +} + +func (p *PaneMap) ResetExtensions() { + if manager := p.app.Extensions(); manager != nil { + manager.NotifyResize(p.dmm) + } +} + func (p *PaneMap) prepareTools() { log.Print("preparing tools:", p.dmm.Name) tools.SetEditor(p.editor) @@ -331,11 +361,15 @@ func (p *PaneMap) syncActivePane() { // Fully reloads a canvas for the current pane. Does a full re-initialization of the renderer. // Needed when changing global parts of the map, like the map size etc. func (p *PaneMap) reloadCanvas() { - oldCamera := p.canvas.Render().Camera // To keep current camera position + previousRenderer := p.canvas.Render() + oldCamera := previousRenderer.Camera // To keep current camera position p.canvas = canvas.New() p.canvas.Render().Camera = oldCamera p.canvas.Render().SetOverlay(p.canvasOverlay) p.canvas.Render().SetUnitProcessor(p) + if manager := p.app.Extensions(); manager != nil { + manager.ReplaceRender(previousRenderer, p.canvas.Render(), p.dmm) + } p.canvas.Render().UpdateBucket(p.dmm, p.activeLevel) p.canvasState.SetMaxX(p.dmm.MaxX) p.canvasState.SetMaxY(p.dmm.MaxY) diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/psettings/psettings.go b/internal/app/ui/cpwsarea/wsmap/pmap/psettings/psettings.go index 41c4d955..16ef9dfa 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/psettings/psettings.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/psettings/psettings.go @@ -2,6 +2,7 @@ package psettings import ( "sdmm/internal/app/config" + "sdmm/internal/app/extensions" "sdmm/internal/app/window" "sdmm/internal/dmapi/dm" "sdmm/internal/dmapi/dmmap" @@ -11,6 +12,7 @@ import ( type App interface { PathsFilter() *dm.PathsFilter + Extensions() *extensions.Manager ConfigRegister(config.Config) } diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/psettings/screenshot.go b/internal/app/ui/cpwsarea/wsmap/pmap/psettings/screenshot.go index 158d96a7..aa2ded06 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/psettings/screenshot.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/psettings/screenshot.go @@ -98,10 +98,16 @@ func (p *Panel) createScreenshot() { c.Render().Camera.Level = p.editor.ActiveLevel() c.Render().Camera.Translate(boundX, boundY) c.Render().SetUnitProcessor(p) + if manager := p.app.Extensions(); manager != nil { + manager.ConfigureRender(c.Render(), p.editor.Dmm()) + } for level := 1; level <= p.editor.ActiveLevel(); level++ { c.Render().UpdateBucket(p.editor.Dmm(), level) // Prepare for render all available levels } c.Process(imgui.Vec2{X: float32(width), Y: float32(height)}) + if manager := p.app.Extensions(); manager != nil { + manager.ReleaseRender(c.Render(), p.editor.Dmm()) + } c.Dispose() var pixels = c.ReadPixels() diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/process.go b/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/process.go index 68481c3d..f2419db8 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/process.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/process.go @@ -62,6 +62,7 @@ func (t *TileMenu) showControls() { Icon(icon.Eraser). Shortcut("Delete"), w.Separator(), + w.Custom(t.showExtensionActions), w.Custom(func() { for idx, instance := range t.tile.Instances().Sorted() { t.showInstance(instance, idx) @@ -70,6 +71,23 @@ func (t *TileMenu) showControls() { }.Build() } +func (t *TileMenu) showExtensionActions() { + if len(t.extensionActions) == 0 { + return + } + manager := t.app.Extensions() + if manager == nil { + return + } + imgui.Separator() + for _, action := range t.extensionActions { + action := action + w.MenuItem(action.Label+"##extension_"+action.ID, func() { + manager.ExecuteContextMenuAction(t.editor.Dmm(), t.tile.Coord, action.ID) + }).IconEmpty().Enabled(action.Enabled).Build() + } +} + func (t *TileMenu) showInstance(i *dmminstance.Instance, idx int) { p := i.Prefab() s := getSprite(p) diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/tilemenu.go b/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/tilemenu.go index f38bf0f2..70c14a23 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/tilemenu.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/tilemenu.go @@ -2,6 +2,7 @@ package tilemenu import ( "sdmm/internal/app/command" + "sdmm/internal/app/extensions" "sdmm/internal/app/prefs" "sdmm/internal/app/ui/cpwsarea/wsmap/pmap/pquickedit" "sdmm/internal/app/ui/shortcut" @@ -39,6 +40,7 @@ type App interface { Prefs() prefs.Prefs LoadedEnvironment() *dmenv.Dme + Extensions() *extensions.Manager } type editor interface { @@ -64,7 +66,8 @@ type TileMenu struct { opened bool - tile *dmmap.Tile + tile *dmmap.Tile + extensionActions []extensions.ContextMenuAction pQuickEdit *pquickedit.Panel } @@ -83,6 +86,9 @@ func (t *TileMenu) Dispose() { func (t *TileMenu) Open(coord util.Point) { if t.editor.Dmm().HasTile(coord) { t.tile = t.editor.Dmm().GetTile(coord) + if manager := t.app.Extensions(); manager != nil { + t.extensionActions = manager.ContextMenuActions(t.editor.Dmm(), coord) + } t.opened = true imgui.OpenPopup("tileMenu") } @@ -91,4 +97,5 @@ func (t *TileMenu) Open(coord util.Point) { func (t *TileMenu) close() { t.opened = false t.tile = nil + t.extensionActions = nil } diff --git a/internal/app/ui/menu/menu.go b/internal/app/ui/menu/menu.go index 102130fe..2ad04869 100644 --- a/internal/app/ui/menu/menu.go +++ b/internal/app/ui/menu/menu.go @@ -2,6 +2,7 @@ package menu import ( "sdmm/internal/app/command" + "sdmm/internal/app/extensions" "sdmm/internal/app/ui/shortcut" "sdmm/internal/dmapi/dm" "sdmm/internal/dmapi/dmenv" @@ -46,6 +47,7 @@ type app interface { DoAreaBorders() DoMultiZRendering() DoMirrorCanvasCamera() + DoExtensionMenuAction(id string) // Window DoResetLayout() @@ -80,6 +82,7 @@ type app interface { AreaBordersRendering() bool MultiZRendering() bool MirrorCanvasCamera() bool + ExtensionMenuActions() []extensions.MenuAction } type upStatus int @@ -108,7 +111,46 @@ func New(app app) *Menu { return m } +func (m *Menu) viewMenu() w.Layout { + items := w.Layout{ + w.MenuItem("Show Area", m.doToggleArea).IconEmpty().Enabled(m.app.HasLoadedEnvironment()).Selected(m.isAreaToggled()).Shortcut(platform.KeyModName(), "1"), + w.MenuItem("Show Turf", m.doToggleTurf).IconEmpty().Enabled(m.app.HasLoadedEnvironment()).Selected(m.isTurfToggled()).Shortcut(platform.KeyModName(), "2"), + w.MenuItem("Show Object", m.doToggleObject).IconEmpty().Enabled(m.app.HasLoadedEnvironment()).Selected(m.isObjectToggled()).Shortcut(platform.KeyModName(), "3"), + w.MenuItem("Show Mob", m.doToggleMob).IconEmpty().Enabled(m.app.HasLoadedEnvironment()).Selected(m.isMobToggled()).Shortcut(platform.KeyModName(), "4"), + w.MenuItem("Show All", m.doShowAll).IconEmpty().Enabled(m.app.HasLoadedEnvironment()), + w.Separator(), + w.MenuItem("Area Borders", m.app.DoAreaBorders).IconEmpty().Selected(m.app.AreaBordersRendering()), + w.MenuItem("Multi-Z Rendering", m.app.DoMultiZRendering).IconEmpty().Selected(m.app.MultiZRendering()).Shortcut(platform.KeyModName(), "0"), + w.MenuItem("Mirror Canvas Camera", m.app.DoMirrorCanvasCamera).IconEmpty().Selected(m.app.MirrorCanvasCamera()), + } + for _, action := range m.app.ExtensionMenuActions() { + if action.Menu != "View" { + continue + } + action := action + items = append(items, w.MenuItem(action.Label, func() { m.app.DoExtensionMenuAction(action.ID) }).IconEmpty().Enabled(action.Enabled).Selected(action.Selected)) + } + return items +} + +func (m *Menu) extensionsMenu() w.Layout { + items := w.Layout{} + for _, action := range m.app.ExtensionMenuActions() { + if action.Menu != "Extensions" { + continue + } + action := action + items = append(items, w.MenuItem(action.Label, func() { m.app.DoExtensionMenuAction(action.ID) }).IconEmpty().Enabled(action.Enabled).Selected(action.Selected)) + } + if len(items) == 0 { + items = append(items, w.MenuItem("No extensions found", func() {}).IconEmpty().Enabled(false)) + } + return items +} + func (m *Menu) Process() { + viewMenu := m.viewMenu() + extensionsMenu := m.extensionsMenu() w.MainMenuBar(w.Layout{ w.Menu("File", w.Layout{ w.MenuItem("New Workspace", m.app.DoNewWorkspace). @@ -200,42 +242,9 @@ func (m *Menu) Process() { Shortcut(platform.KeyModName(), "G"), }), - w.Menu("View", w.Layout{ - w.MenuItem("Show Area", m.doToggleArea). - IconEmpty(). - Enabled(m.app.HasLoadedEnvironment()). - Selected(m.isAreaToggled()). - Shortcut(platform.KeyModName(), "1"), - w.MenuItem("Show Turf", m.doToggleTurf). - IconEmpty(). - Enabled(m.app.HasLoadedEnvironment()). - Selected(m.isTurfToggled()). - Shortcut(platform.KeyModName(), "2"), - w.MenuItem("Show Object", m.doToggleObject). - IconEmpty(). - Enabled(m.app.HasLoadedEnvironment()). - Selected(m.isObjectToggled()). - Shortcut(platform.KeyModName(), "3"), - w.MenuItem("Show Mob", m.doToggleMob). - IconEmpty(). - Enabled(m.app.HasLoadedEnvironment()). - Selected(m.isMobToggled()). - Shortcut(platform.KeyModName(), "4"), - w.MenuItem("Show All", m.doShowAll). - IconEmpty(). - Enabled(m.app.HasLoadedEnvironment()), - w.Separator(), - w.MenuItem("Area Borders", m.app.DoAreaBorders). - IconEmpty(). - Selected(m.app.AreaBordersRendering()), - w.MenuItem("Multi-Z Rendering", m.app.DoMultiZRendering). - IconEmpty(). - Selected(m.app.MultiZRendering()). - Shortcut(platform.KeyModName(), "0"), - w.MenuItem("Mirror Canvas Camera", m.app.DoMirrorCanvasCamera). - IconEmpty(). - Selected(m.app.MirrorCanvasCamera()), - }), + w.Menu("View", viewMenu), + + w.Menu("Extensions", extensionsMenu), w.Menu("Window", w.Layout{ w.MenuItem("Reset Layout", m.app.DoResetLayout).Shortcut("F5"). diff --git a/tools/sdmmpack/go.mod b/tools/sdmmpack/go.mod new file mode 100644 index 00000000..a8e3683c --- /dev/null +++ b/tools/sdmmpack/go.mod @@ -0,0 +1,3 @@ +module github.com/SpaiR/StrongDMM/tools/sdmmpack + +go 1.24.0 diff --git a/tools/sdmmpack/main.go b/tools/sdmmpack/main.go new file mode 100644 index 00000000..ff3177e2 --- /dev/null +++ b/tools/sdmmpack/main.go @@ -0,0 +1,94 @@ +// sdmmpack packages prebuilt extension commands into a .sdmmext archive. +package main + +import ( + "archive/zip" + "encoding/json" + "flag" + "io" + "os" + "path/filepath" +) + +type manifest struct { + Commands map[string]string `json:"commands"` +} + +func main() { + source := flag.String("source", ".", "directory containing extension commands") + manifestPath := flag.String("manifest", "extension.json", "extension manifest") + output := flag.String("output", "extension.sdmmext", "output package") + flag.Parse() + data, err := os.ReadFile(*manifestPath) + if err != nil { + panic(err) + } + var extension manifest + if err = json.Unmarshal(data, &extension); err != nil { + panic(err) + } + stage, err := os.MkdirTemp("", "sdmmpack-") + if err != nil { + panic(err) + } + defer os.RemoveAll(stage) + for platform, command := range extension.Commands { + if command == "" || filepath.IsAbs(command) || filepath.Dir(command) == "." { + panic("invalid command for " + platform) + } + destination := filepath.Join(stage, command) + if err = os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + panic(err) + } + copyFile(destination, filepath.Join(*source, command)) + } + copyFile(filepath.Join(stage, "extension.json"), *manifestPath) + if err = os.MkdirAll(filepath.Dir(*output), 0755); err != nil { + panic(err) + } + archive, err := os.Create(*output) + if err != nil { + panic(err) + } + defer archive.Close() + zipper := zip.NewWriter(archive) + err = filepath.Walk(stage, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil || info.IsDir() { + return walkErr + } + relative, _ := filepath.Rel(stage, path) + entry, err := zipper.Create(filepath.ToSlash(relative)) + if err != nil { + return err + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + _, err = io.Copy(entry, file) + return err + }) + if err != nil { + panic(err) + } + if err = zipper.Close(); err != nil { + panic(err) + } +} + +func copyFile(destination, source string) { + input, err := os.Open(source) + if err != nil { + panic(err) + } + defer input.Close() + output, err := os.Create(destination) + if err != nil { + panic(err) + } + defer output.Close() + if _, err = io.Copy(output, input); err != nil { + panic(err) + } +} From 044f383e35876eeb9a19157e02e00bc1806389f5 Mon Sep 17 00:00:00 2001 From: CabinetOnFire <> Date: Sat, 1 Aug 2026 23:05:48 +0200 Subject: [PATCH 02/11] simplification --- Taskfile.yml | 8 ++++++++ extensions/README.md | 4 +--- tools/sdmmpack/main.go | 28 +++++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index ac03107e..59aff3b0 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -63,6 +63,14 @@ tasks: cmds: - go run . -source "{{.EXTENSION_DIR}}" -manifest "{{.EXTENSION_DIR}}/extension.json" -output "{{.EXTENSION_OUTPUT}}" + build-extension: + desc: Build every command declared by an extension manifest and package it + dir: tools/sdmmpack + requires: + vars: [EXTENSION_DIR, EXTENSION_OUTPUT] + cmds: + - go run . -build -source "{{.EXTENSION_DIR}}" -manifest "{{.EXTENSION_DIR}}/extension.json" -output "{{.EXTENSION_OUTPUT}}" + run: deps: - build-parser diff --git a/extensions/README.md b/extensions/README.md index 989866ec..dae54463 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -1,8 +1,6 @@ # StrongDMM extensions -Extensions are Go executables managed by StrongDMM through HashiCorp -go-plugin's versioned net/rpc transport. They run out of process, so an -extension failure cannot mangle the editor. +Extensions are Go executables via go-plugin to extend the editor in different ways. ```json { diff --git a/tools/sdmmpack/main.go b/tools/sdmmpack/main.go index ff3177e2..3839486a 100644 --- a/tools/sdmmpack/main.go +++ b/tools/sdmmpack/main.go @@ -7,7 +7,9 @@ import ( "flag" "io" "os" + "os/exec" "path/filepath" + "strings" ) type manifest struct { @@ -15,9 +17,10 @@ type manifest struct { } func main() { - source := flag.String("source", ".", "directory containing extension commands") + source := flag.String("source", ".", "extension source directory") manifestPath := flag.String("manifest", "extension.json", "extension manifest") output := flag.String("output", "extension.sdmmext", "output package") + build := flag.Bool("build", false, "build commands declared by the manifest before packaging") flag.Parse() data, err := os.ReadFile(*manifestPath) if err != nil { @@ -27,6 +30,29 @@ func main() { if err = json.Unmarshal(data, &extension); err != nil { panic(err) } + if *build { + for platform, command := range extension.Commands { + goos, goarch, ok := strings.Cut(platform, "-") + if !ok || goos == "" || goarch == "" { + panic("invalid platform " + platform) + } + if command == "" || filepath.IsAbs(command) || filepath.Dir(command) == "." { + panic("invalid command for " + platform) + } + path := filepath.Join(*source, command) + if err = os.MkdirAll(filepath.Dir(path), 0755); err != nil { + panic(err) + } + buildCommand := exec.Command("go", "build", "-o", path, ".") + buildCommand.Dir = *source + buildCommand.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch) + buildCommand.Stdout = os.Stdout + buildCommand.Stderr = os.Stderr + if err = buildCommand.Run(); err != nil { + panic(err) + } + } + } stage, err := os.MkdirTemp("", "sdmmpack-") if err != nil { panic(err) From 255c458df0067e436de214401a36e6e42b4181bc Mon Sep 17 00:00:00 2001 From: CabinetOnFire <> Date: Sun, 2 Aug 2026 00:48:26 +0200 Subject: [PATCH 03/11] update docs --- extensions/README.md | 14 +++++--------- extensions/example/extension.json | 7 ++++--- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/extensions/README.md b/extensions/README.md index dae54463..c3d8ee33 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -14,12 +14,6 @@ Extensions are Go executables via go-plugin to extend the editor in different wa } ``` -Build each command for its target platform: - -```text -go build -o bin/windows-amd64/example.exe . -``` - Implement `extensionapi.Extension` and call `plugin.Serve` with `extensionapi.HandshakeConfig` and an `extensionapi.Plugin`. The API exposes versioned `Request` values and request-shaped responses. Rendering receives map @@ -40,12 +34,14 @@ cycles are logged. `mesh` commands support RGBA vertex colors; `bilinear` commands are intentionally opaque and support normal, multiply, or additive blending. -Package prebuilt commands with: +Build and package an extension with StrongDMM's generic Task: ```text -cd tools/sdmmpack -go run . -source path/to/commands -manifest extension.json -output example.sdmmext +task --taskfile path/to/StrongDMM/Taskfile.yml build-extension EXTENSION_DIR=path/to/extension EXTENSION_OUTPUT=path/to/extension/dist/extension.sdmmext ``` +The task cross-builds every command declared in `extension.json` and packages +the resulting `.sdmmext` archive. + Extension API versions must match the host. Build and package extensions only from trusted source. diff --git a/extensions/example/extension.json b/extensions/example/extension.json index 1560cccd..a2b8c0f0 100644 --- a/extensions/example/extension.json +++ b/extensions/example/extension.json @@ -2,10 +2,11 @@ "id": "example.extension", "name": "Example Extension", "apiVersion": 1, + "capabilities": ["render"], "commands": { - "windows-amd64": ["bin/windows-amd64/example-extension.exe"], - "linux-amd64": ["bin/linux-amd64/example-extension"], - "darwin-arm64": ["bin/darwin-arm64/example-extension"] + "windows-amd64": "bin/windows-amd64/example-extension.exe", + "linux-amd64": "bin/linux-amd64/example-extension", + "darwin-arm64": "bin/darwin-arm64/example-extension" }, "actions": [ {"id": "enabled", "menu": "Extensions", "label": "Enable Example Extension", "kind": "toggle-enabled"} From fd5312dc5ae97819e4ce11c039fb5fe52a8f371a Mon Sep 17 00:00:00 2001 From: CabinetOnFire <> Date: Sun, 2 Aug 2026 01:06:40 +0200 Subject: [PATCH 04/11] old comment --- internal/app/ui/cpwsarea/wsmap/pmap/pmap.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go b/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go index a2e6b746..34052350 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go @@ -261,9 +261,7 @@ func (p *PaneMap) Dispose() { log.Print("disposed") } -// RefreshExtensions updates this map after extension settings or map contents -// change. It is intentionally small so other extension render layers can use -// the same host hook later. +// RefreshExtensions updates this map after extension settings or map contents change func (p *PaneMap) RefreshExtensions() { if manager := p.app.Extensions(); manager != nil { manager.RefreshRender(p.canvas.Render(), p.dmm) From f5d473c4a1886710c149a4d47271042635480d93 Mon Sep 17 00:00:00 2001 From: CabinetOnFire <> Date: Sun, 2 Aug 2026 01:28:40 +0200 Subject: [PATCH 05/11] dont open a console pls --- internal/app/extensions/command_other.go | 7 +++++++ internal/app/extensions/command_windows.go | 14 ++++++++++++++ internal/app/extensions/package.go | 3 +-- 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 internal/app/extensions/command_other.go create mode 100644 internal/app/extensions/command_windows.go diff --git a/internal/app/extensions/command_other.go b/internal/app/extensions/command_other.go new file mode 100644 index 00000000..448ed05b --- /dev/null +++ b/internal/app/extensions/command_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package extensions + +import "os/exec" + +func extensionCommand(path string) *exec.Cmd { return exec.Command(path) } diff --git a/internal/app/extensions/command_windows.go b/internal/app/extensions/command_windows.go new file mode 100644 index 00000000..f5f6baa9 --- /dev/null +++ b/internal/app/extensions/command_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package extensions + +import ( + "os/exec" + "syscall" +) + +func extensionCommand(path string) *exec.Cmd { + command := exec.Command(path) + command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + return command +} diff --git a/internal/app/extensions/package.go b/internal/app/extensions/package.go index a6ea354f..aa2d60d9 100644 --- a/internal/app/extensions/package.go +++ b/internal/app/extensions/package.go @@ -7,7 +7,6 @@ import ( "encoding/json" "io" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -61,7 +60,7 @@ func newPackageClient(packagePath, cacheRoot string) *client { process := plugin.NewClient(&plugin.ClientConfig{ HandshakeConfig: api.HandshakeConfig, Plugins: plugin.PluginSet{api.PluginName: &api.Plugin{}}, - Cmd: exec.Command(binary), + Cmd: extensionCommand(binary), AllowedProtocols: []plugin.Protocol{plugin.ProtocolNetRPC}, Logger: hclog.New(&hclog.LoggerOptions{Name: manifest.ID, Output: os.Stderr}), }) From 60dbaba689b658a5d6c2829b1068fe846dab6936 Mon Sep 17 00:00:00 2001 From: CabinetOnFire <> Date: Mon, 3 Aug 2026 16:14:26 +0200 Subject: [PATCH 06/11] Adds support for color floors in the shader and sliders for extensions, also remove that forced refresh because its no longer needed --- extensionapi/extensionapi.go | 3 + internal/app/action.go | 7 ++ internal/app/action_user.go | 1 - internal/app/extensions/manager.go | 103 ++++++++++++++++++++++-- internal/app/render/brush/bilinear.go | 3 +- internal/app/render/brush/brush.go | 2 + internal/app/render/brush/shader.go | 4 +- internal/app/render/extension_passes.go | 2 +- internal/app/ui/menu/menu.go | 17 ++++ 9 files changed, 130 insertions(+), 12 deletions(-) diff --git a/extensionapi/extensionapi.go b/extensionapi/extensionapi.go index cd280010..86ed1cb3 100644 --- a/extensionapi/extensionapi.go +++ b/extensionapi/extensionapi.go @@ -52,6 +52,7 @@ type RenderPass struct { ID string After, Before []string Blend string + ColorFloor float32 Commands []RenderCommand } type RenderPatch struct { @@ -61,6 +62,7 @@ type RenderPatch struct { } type RenderUpdate struct { Replace []RenderPass + UpsertPasses []RenderPass Patches []RenderPatch RemovePasses []string } @@ -92,6 +94,7 @@ type Request struct { MapID string Revision uint64 Settings map[string]bool + Values map[string]float64 Map *Map Update *MapUpdate Render *RenderUpdate diff --git a/internal/app/action.go b/internal/app/action.go index 42918d91..ce533730 100644 --- a/internal/app/action.go +++ b/internal/app/action.go @@ -247,6 +247,13 @@ func (a *app) ExtensionMenuActions() []extensions.MenuAction { return a.extensions.MenuActions() } +func (a *app) SetExtensionMenuValue(id string, value int) { + if a.extensions == nil { + return + } + a.extensions.SetMenuValue(id, value) +} + // FocusApplicationWindow explicitly moves an OS focus to the current application window. func (a *app) FocusApplicationWindow() { a.masterWindow.Handle().Focus() diff --git a/internal/app/action_user.go b/internal/app/action_user.go index 7748d6c2..b39b5c40 100644 --- a/internal/app/action_user.go +++ b/internal/app/action_user.go @@ -339,7 +339,6 @@ func (a *app) DoExtensionMenuAction(id string) { return } a.extensions.ExecuteMenuAction(id) - a.layout.WsArea.RefreshExtensions() log.Print("do extension action:", id) } diff --git a/internal/app/extensions/manager.go b/internal/app/extensions/manager.go index 4f5292ca..bd556428 100644 --- a/internal/app/extensions/manager.go +++ b/internal/app/extensions/manager.go @@ -22,10 +22,13 @@ type ExtensionState struct { Disabled bool Preview bool Settings map[string]bool + Values map[string]int } type MenuAction struct { ID, Menu, Label string Enabled, Selected bool + Kind string + Value, Min, Max int } type ContextMenuAction struct { ID, Label string @@ -80,6 +83,13 @@ func (m *Manager) settings(client *client) map[string]bool { } return settings } +func (m *Manager) values(client *client) map[string]float64 { + values := make(map[string]float64) + for id, value := range m.stateFor(client).Values { + values[id] = float64(value) + } + return values +} func (m *Manager) MenuActions() []MenuAction { actions := make([]MenuAction, 0) @@ -93,14 +103,49 @@ func (m *Manager) MenuActions() []MenuAction { selected, enabled = m.preview(client), m.enabled(client) case "toggle": selected, enabled = m.stateFor(client).Settings[action.ID], m.enabled(client) + case "slider": + enabled = m.enabled(client) default: continue } - actions = append(actions, MenuAction{ID: client.manifest.ID + ":" + action.ID, Menu: action.Menu, Label: action.Label, Enabled: enabled, Selected: selected}) + value := m.stateFor(client).Values[action.ID] + if value == 0 && action.Default != 0 { + value = action.Default + } + actions = append(actions, MenuAction{ID: client.manifest.ID + ":" + action.ID, Menu: action.Menu, Label: action.Label, Enabled: enabled, Selected: selected, Kind: action.Kind, Value: value, Min: action.Min, Max: action.Max}) } } return actions } +func (m *Manager) SetMenuValue(id string, value int) { + for _, client := range m.clients { + for _, action := range client.manifest.Actions { + if action.Kind != "slider" || id != client.manifest.ID+":"+action.ID { + continue + } + if value < action.Min { + value = action.Min + } + if value > action.Max { + value = action.Max + } + state := m.stateFor(client) + if state.Values == nil { + state.Values = make(map[string]int) + } + state.Values[action.ID] = value + if action.RenderPass != "" && action.RenderProperty != "" { + for _, doc := range m.documents { + m.applyRenderSetting(client, doc) + m.render(doc) + } + return + } + m.refreshClient(client) + return + } + } +} func (m *Manager) ExecuteMenuAction(id string) { for _, client := range m.clients { @@ -114,6 +159,10 @@ func (m *Manager) ExecuteMenuAction(id string) { state.Disabled = !state.Disabled case "toggle-preview": state.Preview = !state.Preview + for _, doc := range m.documents { + m.render(doc) + } + return case "toggle": if state.Settings == nil { state.Settings = make(map[string]bool) @@ -216,8 +265,8 @@ func (m *Manager) NotifyChanged(dmm *dmmap.Dmm, points []util.Point) { doc.revision++ update := m.update(doc, dmm, points) for _, client := range m.clients { - if m.enabled(client) && m.preview(client) && client.usesMap() { - client.queue(work{message: api.Request{Version: api.ProtocolVersion, Type: "map.update", MapID: doc.id, Revision: doc.revision, Update: &update}}) + if m.enabled(client) && client.usesMap() { + client.queue(work{message: api.Request{Version: api.ProtocolVersion, Type: "map.update", MapID: doc.id, Revision: doc.revision, Values: m.values(client), Update: &update}}) } } } @@ -270,21 +319,27 @@ func (m *Manager) queueOpen(doc *document, dmm *dmmap.Dmm) { doc.opened = true mapData := m.mapData(doc, dmm, nil) for _, client := range m.clients { - if m.enabled(client) && m.preview(client) && client.usesMap() { - client.queue(work{immediate: true, message: api.Request{Version: api.ProtocolVersion, Type: "map.open", MapID: doc.id, Revision: doc.revision, Settings: m.settings(client), Map: &mapData}}) + if m.enabled(client) && client.usesMap() { + client.queue(work{immediate: true, message: api.Request{Version: api.ProtocolVersion, Type: "map.open", MapID: doc.id, Revision: doc.revision, Settings: m.settings(client), Values: m.values(client), Map: &mapData}}) } } } func (m *Manager) refreshClient(client *client) { for dmm, doc := range m.documents { - if !m.enabled(client) || !m.preview(client) || !client.usesMap() { + if !m.enabled(client) || !client.usesMap() { delete(doc.outputs, client) + delete(doc.applied, client) m.render(doc) continue } + m.render(doc) + if _, tracked := doc.applied[client]; tracked { + client.queue(work{immediate: true, message: api.Request{Version: api.ProtocolVersion, Type: "settings.update", MapID: doc.id, Revision: doc.revision, Settings: m.settings(client), Values: m.values(client)}}) + continue + } mapData := m.mapData(doc, dmm, nil) - client.queue(work{immediate: true, message: api.Request{Version: api.ProtocolVersion, Type: "map.open", MapID: doc.id, Revision: doc.revision, Settings: m.settings(client), Map: &mapData}}) + client.queue(work{immediate: true, message: api.Request{Version: api.ProtocolVersion, Type: "map.open", MapID: doc.id, Revision: doc.revision, Settings: m.settings(client), Values: m.values(client), Map: &mapData}}) } } @@ -325,6 +380,9 @@ func (m *Manager) apply(client *client, result workerResult) { for _, pass := range message.Render.Replace { passes[pass.ID] = pass } + for _, pass := range message.Render.UpsertPasses { + passes[pass.ID] = pass + } doc.outputs[client] = passes } for _, passID := range message.Render.RemovePasses { @@ -352,9 +410,31 @@ func (m *Manager) apply(client *client, result workerResult) { sort.Slice(pass.Commands, func(i, j int) bool { return pass.Commands[i].ID < pass.Commands[j].ID }) passes[pass.ID] = pass } + m.applyRenderSetting(client, doc) doc.applied[client] = message.Revision m.render(doc) } +func (m *Manager) applyRenderSetting(client *client, doc *document) { + passes := doc.outputs[client] + for _, action := range client.manifest.Actions { + if action.Kind != "slider" || action.RenderPass == "" { + continue + } + pass, exists := passes[action.RenderPass] + if !exists { + continue + } + value := m.stateFor(client).Values[action.ID] + if value == 0 && action.Default != 0 { + value = action.Default + } + switch action.RenderProperty { + case "color_floor": + pass.ColorFloor = float32(value) / 100 + } + passes[pass.ID] = pass + } +} func (m *Manager) render(doc *document) { passes := m.extensionPasses(doc) @@ -370,6 +450,9 @@ func (m *Manager) extensionPasses(doc *document) []api.RenderPass { passes := make([]api.RenderPass, 0) seen := make(map[string]struct{}) for _, client := range m.clients { + if !m.preview(client) { + continue + } clientPasses := doc.outputs[client] ids := make([]string, 0, len(clientPasses)) for id := range clientPasses { @@ -484,7 +567,11 @@ type manifest struct { Capabilities []string `json:"capabilities"` Actions []manifestAction `json:"actions"` } -type manifestAction struct{ ID, Menu, Label, Kind string } +type manifestAction struct { + ID, Menu, Label, Kind string + RenderPass, RenderProperty string + Min, Max, Default int +} type work struct { message api.Request immediate bool diff --git a/internal/app/render/brush/bilinear.go b/internal/app/render/brush/bilinear.go index 06f65674..5ecdba4b 100644 --- a/internal/app/render/brush/bilinear.go +++ b/internal/app/render/brush/bilinear.go @@ -13,7 +13,7 @@ type BilinearQuad struct { SouthWest, SouthEast, NorthWest, NorthEast Color } -func BilinearQuads(quads []BilinearQuad, w, h, x, y, z float32) { +func BilinearQuads(quads []BilinearQuad, w, h, x, y, z, colorFloor float32) { if len(quads) == 0 { return } @@ -35,6 +35,7 @@ func BilinearQuads(quads []BilinearQuad, w, h, x, y, z float32) { gl.BindVertexArray(bilinearVAO) mtx := transformationMatrix(w, h, x, y, z) gl.UniformMatrix4fv(bilinearTransform, 1, false, &mtx[0]) + gl.Uniform1f(bilinearColorFloor, colorFloor) gl.BindBuffer(gl.ARRAY_BUFFER, bilinearVBO) gl.BufferData(gl.ARRAY_BUFFER, len(data)*platform.FloatSize, gl.Ptr(data), gl.STREAM_DRAW) gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, bilinearEBO) diff --git a/internal/app/render/brush/brush.go b/internal/app/render/brush/brush.go index 019c1b34..3170ea2c 100644 --- a/internal/app/render/brush/brush.go +++ b/internal/app/render/brush/brush.go @@ -22,6 +22,7 @@ var ( uniformLocationTransform int32 uniformLocationHasTexture int32 bilinearTransform int32 + bilinearColorFloor int32 bilinearVAO uint32 bilinearVBO uint32 bilinearEBO uint32 @@ -78,6 +79,7 @@ func initBilinear() { log.Fatal().Msgf("unable to create bilinear shader: %v", err) } bilinearTransform = gl.GetUniformLocation(bilinearProgram, gl.Str("Transform\x00")) + bilinearColorFloor = gl.GetUniformLocation(bilinearProgram, gl.Str("ColorFloor\x00")) gl.GenVertexArrays(1, &bilinearVAO) gl.GenBuffers(1, &bilinearVBO) gl.GenBuffers(1, &bilinearEBO) diff --git a/internal/app/render/brush/shader.go b/internal/app/render/brush/shader.go index 169cea07..5339d71c 100644 --- a/internal/app/render/brush/shader.go +++ b/internal/app/render/brush/shader.go @@ -72,6 +72,7 @@ void main() { func bilinearFragmentShader() string { return ` #version 330 core +uniform float ColorFloor; flat in vec3 south_west; flat in vec3 south_east; flat in vec3 north_west; @@ -81,7 +82,8 @@ out vec4 outputColor; void main() { vec3 south = mix(south_west, south_east, uv.x); vec3 north = mix(north_west, north_east, uv.x); - outputColor = vec4(mix(south, north, uv.y), 1.0); + vec3 color = mix(south, north, uv.y); + outputColor = vec4(vec3(ColorFloor) + color * (1.0 - ColorFloor), 1.0); } ` + "\x00" } diff --git a/internal/app/render/extension_passes.go b/internal/app/render/extension_passes.go index 8621d3ef..9ea6313b 100644 --- a/internal/app/render/extension_passes.go +++ b/internal/app/render/extension_passes.go @@ -136,7 +136,7 @@ func (r *Render) drawExtensionPass(pass api.RenderPass, width, height float32) { for _, quad := range command.Mesh.Quads { quads = append(quads, brush.BilinearQuad{X1: quad.X1, Y1: quad.Y1, X2: quad.X2, Y2: quad.Y2, SouthWest: brush.Color(quad.SouthWest), SouthEast: brush.Color(quad.SouthEast), NorthWest: brush.Color(quad.NorthWest), NorthEast: brush.Color(quad.NorthEast)}) } - brush.BilinearQuads(quads, width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + brush.BilinearQuads(quads, width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale, pass.ColorFloor) } } brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) diff --git a/internal/app/ui/menu/menu.go b/internal/app/ui/menu/menu.go index 2ad04869..b7efc83c 100644 --- a/internal/app/ui/menu/menu.go +++ b/internal/app/ui/menu/menu.go @@ -48,6 +48,7 @@ type app interface { DoMultiZRendering() DoMirrorCanvasCamera() DoExtensionMenuAction(id string) + SetExtensionMenuValue(id string, value int) // Window DoResetLayout() @@ -128,11 +129,27 @@ func (m *Menu) viewMenu() w.Layout { continue } action := action + if action.Kind == "slider" { + items = append(items, extensionSlider{action: action, set: m.app.SetExtensionMenuValue}) + continue + } items = append(items, w.MenuItem(action.Label, func() { m.app.DoExtensionMenuAction(action.ID) }).IconEmpty().Enabled(action.Enabled).Selected(action.Selected)) } return items } +type extensionSlider struct { + action extensions.MenuAction + set func(string, int) +} + +func (s extensionSlider) Build() { + value := int32(s.action.Value) + if imgui.SliderInt(s.action.Label+"##"+s.action.ID, &value, int32(s.action.Min), int32(s.action.Max)) { + s.set(s.action.ID, int(value)) + } +} + func (m *Menu) extensionsMenu() w.Layout { items := w.Layout{} for _, action := range m.app.ExtensionMenuActions() { From c5a2feb9d26d30da31a63fa4b71f1cb55cdad7ee Mon Sep 17 00:00:00 2001 From: CabinetOnFire <> Date: Mon, 3 Aug 2026 16:30:27 +0200 Subject: [PATCH 07/11] passes were dropped woops --- internal/app/extensions/manager.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/app/extensions/manager.go b/internal/app/extensions/manager.go index bd556428..5d023cd0 100644 --- a/internal/app/extensions/manager.go +++ b/internal/app/extensions/manager.go @@ -380,11 +380,11 @@ func (m *Manager) apply(client *client, result workerResult) { for _, pass := range message.Render.Replace { passes[pass.ID] = pass } - for _, pass := range message.Render.UpsertPasses { - passes[pass.ID] = pass - } doc.outputs[client] = passes } + for _, pass := range message.Render.UpsertPasses { + passes[pass.ID] = pass + } for _, passID := range message.Render.RemovePasses { delete(passes, passID) } From 91023cb3221780677c61be1ad1c577c958dd0103 Mon Sep 17 00:00:00 2001 From: CabinetOnFire <> Date: Tue, 4 Aug 2026 22:59:43 +0200 Subject: [PATCH 08/11] Support for appearance-patching --- extensionapi/extensionapi.go | 33 ++++- internal/app/extensions/manager.go | 91 +++++++++++- internal/app/render/appearance.go | 129 ++++++++++++++++++ internal/app/render/bucket.go | 19 ++- .../render/bucket/level/chunk/unit/unit.go | 19 ++- internal/app/render/render.go | 2 + 6 files changed, 278 insertions(+), 15 deletions(-) create mode 100644 internal/app/render/appearance.go diff --git a/extensionapi/extensionapi.go b/extensionapi/extensionapi.go index 86ed1cb3..0612d5ee 100644 --- a/extensionapi/extensionapi.go +++ b/extensionapi/extensionapi.go @@ -16,6 +16,7 @@ const ( CapabilityRender = "render" CapabilityContextMenu = "context-menu" + CapabilityAppearance = "appearance" ) type Color struct{ R, G, B float32 } @@ -24,9 +25,16 @@ type AtomDefinition struct { Path string Vars map[string]string } + +// AtomInstance identifies one map atom. DefinitionID describes its resolved +// prefab while ID remains stable for the lifetime of the editor instance. +type AtomInstance struct { + ID uint64 + DefinitionID uint32 +} type Tile struct { X, Y, Z int - Atoms []uint32 + Atoms []AtomInstance } type Map struct { Width, Height, Levels int @@ -66,6 +74,28 @@ type RenderUpdate struct { Patches []RenderPatch RemovePasses []string } + +// Appearance is a partial visual appearance. Nil pointers mean that the field +// is left unchanged, allowing several extensions to compose their patches. +type Appearance struct { + Icon, IconState *string + Dir *int + Color *string + Alpha *float32 + PixelX, PixelY *int + PixelW, PixelZ *int + Visible *bool +} +type AppearancePatch struct { + AtomID uint64 + Appearance Appearance + Underlays []Appearance +} +type AppearanceUpdate struct { + Reset bool + Upsert []AppearancePatch + Remove []uint64 +} type MapUpdate struct { Width, Height, Levels int Definitions []AtomDefinition @@ -98,6 +128,7 @@ type Request struct { Map *Map Update *MapUpdate Render *RenderUpdate + Appearance *AppearanceUpdate Context *ContextMenuRequest Action *ActionRequest ContextMenu *ContextMenuResponse diff --git a/internal/app/extensions/manager.go b/internal/app/extensions/manager.go index 5d023cd0..c272e60b 100644 --- a/internal/app/extensions/manager.go +++ b/internal/app/extensions/manager.go @@ -50,6 +50,7 @@ type document struct { nextDefinition uint32 renderers map[*render.Render]struct{} outputs map[*client]map[string]api.RenderPass + appearances map[*client]map[uint64]api.AppearancePatch applied map[*client]uint64 newDefinitions []api.AtomDefinition } @@ -214,7 +215,7 @@ func (m *Manager) ExecuteContextMenuAction(dmm *dmmap.Dmm, point util.Point, id log.Warn().Err(err).Str("extension", client.manifest.ID).Msg("extension context action failed") return } - m.apply(client, workerResult{message: api.Request{Version: response.Version, Type: response.Type, MapID: response.MapID, Revision: response.Revision, Render: response.Render}}) + m.apply(client, workerResult{message: api.Request{Version: response.Version, Type: response.Type, MapID: response.MapID, Revision: response.Revision, Render: response.Render, Appearance: response.Appearance}}) return } } @@ -223,6 +224,7 @@ func (m *Manager) ConfigureRender(renderer *render.Render, dmm *dmmap.Dmm) { doc := m.document(dmm) doc.renderers[renderer] = struct{}{} renderer.SetExtensionPasses(m.extensionPasses(doc)) + renderer.SetAppearancePatches(m.extensionAppearances(doc)) if !doc.opened { m.queueOpen(doc, dmm) } @@ -258,6 +260,7 @@ func (m *Manager) ReplaceRender(previous, next *render.Render, dmm *dmmap.Dmm) { delete(doc.renderers, previous) doc.renderers[next] = struct{}{} next.SetExtensionPasses(m.extensionPasses(doc)) + next.SetAppearancePatches(m.extensionAppearances(doc)) } func (m *Manager) NotifyChanged(dmm *dmmap.Dmm, points []util.Point) { @@ -307,7 +310,7 @@ func (m *Manager) document(dmm *dmmap.Dmm) *document { if doc := m.documents[dmm]; doc != nil { return doc } - doc := &document{id: fmt.Sprintf("map-%p", dmm), definitions: make(map[string]uint32), atoms: make(map[uint32]api.AtomDefinition), nextDefinition: 1, renderers: make(map[*render.Render]struct{}), outputs: make(map[*client]map[string]api.RenderPass), applied: make(map[*client]uint64)} + doc := &document{id: fmt.Sprintf("map-%p", dmm), definitions: make(map[string]uint32), atoms: make(map[uint32]api.AtomDefinition), nextDefinition: 1, renderers: make(map[*render.Render]struct{}), outputs: make(map[*client]map[string]api.RenderPass), appearances: make(map[*client]map[uint64]api.AppearancePatch), applied: make(map[*client]uint64)} m.documents[dmm] = doc return doc } @@ -329,6 +332,7 @@ func (m *Manager) refreshClient(client *client) { for dmm, doc := range m.documents { if !m.enabled(client) || !client.usesMap() { delete(doc.outputs, client) + delete(doc.appearances, client) delete(doc.applied, client) m.render(doc) continue @@ -367,7 +371,27 @@ func (m *Manager) apply(client *client, result workerResult) { } } } - if message.Type != "render.update" || message.Render == nil || message.Revision < doc.applied[client] { + if (message.Type != "render.update" && message.Type != "appearance.update") || message.Revision < doc.applied[client] { + return + } + if message.Appearance != nil { + patches := doc.appearances[client] + if message.Appearance.Reset || patches == nil { + patches = make(map[uint64]api.AppearancePatch) + doc.appearances[client] = patches + } + for _, id := range message.Appearance.Remove { + delete(patches, id) + } + for _, patch := range message.Appearance.Upsert { + if patch.AtomID != 0 { + patches[patch.AtomID] = patch + } + } + } + if message.Render == nil { + doc.applied[client] = message.Revision + m.render(doc) return } passes := doc.outputs[client] @@ -438,9 +462,62 @@ func (m *Manager) applyRenderSetting(client *client, doc *document) { func (m *Manager) render(doc *document) { passes := m.extensionPasses(doc) + appearances := m.extensionAppearances(doc) for renderer := range doc.renderers { renderer.SetExtensionPasses(passes) + renderer.SetAppearancePatches(appearances) + } +} + +func (m *Manager) extensionAppearances(doc *document) map[uint64]api.AppearancePatch { + result := make(map[uint64]api.AppearancePatch) + for _, client := range m.clients { + if !m.preview(client) { + continue + } + for id, patch := range doc.appearances[client] { + previous := result[id] + previous.AtomID = id + previous.Appearance = mergeAppearance(previous.Appearance, patch.Appearance) + previous.Underlays = append(previous.Underlays, patch.Underlays...) + result[id] = previous + } + } + return result +} + +func mergeAppearance(base, patch api.Appearance) api.Appearance { + if patch.Icon != nil { + base.Icon = patch.Icon + } + if patch.IconState != nil { + base.IconState = patch.IconState + } + if patch.Dir != nil { + base.Dir = patch.Dir + } + if patch.Color != nil { + base.Color = patch.Color + } + if patch.Alpha != nil { + base.Alpha = patch.Alpha + } + if patch.PixelX != nil { + base.PixelX = patch.PixelX + } + if patch.PixelY != nil { + base.PixelY = patch.PixelY + } + if patch.PixelW != nil { + base.PixelW = patch.PixelW + } + if patch.PixelZ != nil { + base.PixelZ = patch.PixelZ + } + if patch.Visible != nil { + base.Visible = patch.Visible } + return base } // extensionPasses produces a stable global pass list. Client discovery is @@ -523,10 +600,10 @@ func (m *Manager) tile(doc *document, tile *dmmap.Tile) api.Tile { atom := api.AtomDefinition{ID: id, Path: instance.Prefab().Path(), Vars: vars} doc.atoms[id] = atom doc.newDefinitions = append(doc.newDefinitions, atom) - out.Atoms = append(out.Atoms, id) + out.Atoms = append(out.Atoms, api.AtomInstance{ID: instance.Id(), DefinitionID: id}) continue } - out.Atoms = append(out.Atoms, id) + out.Atoms = append(out.Atoms, api.AtomInstance{ID: instance.Id(), DefinitionID: id}) } return out } @@ -598,7 +675,7 @@ func (c *client) hasCapability(capability string) bool { return false } func (c *client) usesMap() bool { - return c.hasCapability(api.CapabilityRender) || c.hasCapability(api.CapabilityContextMenu) + return c.hasCapability(api.CapabilityRender) || c.hasCapability(api.CapabilityContextMenu) || c.hasCapability(api.CapabilityAppearance) } func (c *client) startWorker() { @@ -680,7 +757,7 @@ func (c *client) execute(work work) { } func (c *client) request(message api.Request) (api.Request, error) { response, err := c.extension.Handle(message) - return api.Request{Version: response.Version, Type: response.Type, MapID: response.MapID, Revision: response.Revision, Render: response.Render, Reason: response.Reason}, err + return api.Request{Version: response.Version, Type: response.Type, MapID: response.MapID, Revision: response.Revision, Render: response.Render, Appearance: response.Appearance, Reason: response.Reason}, err } func discover(root string) []*client { diff --git a/internal/app/render/appearance.go b/internal/app/render/appearance.go new file mode 100644 index 00000000..7955e4d4 --- /dev/null +++ b/internal/app/render/appearance.go @@ -0,0 +1,129 @@ +package render + +import ( + "sdmm/internal/app/render/bucket/level/chunk/unit" + "sdmm/internal/dmapi/dmicon" + "sdmm/internal/util" + + api "github.com/SpaiR/StrongDMM-extension-api" +) + +// resolvedAppearance contains only renderer-ready values. It is rebuilt when extension output changes, never while drawing a frame. +type resolvedAppearance struct { + appearance api.AppearancePatch + compiled bool + visible bool + sprite *dmicon.Sprite + pixelX, pixelY, pixelW, pixelZ int + r, g, b, a float32 + colorSet bool + underlays []*dmicon.Sprite +} + +func (r *Render) SetAppearancePatches(patches map[uint64]api.AppearancePatch) { + r.appearances = make(map[uint64]resolvedAppearance, len(patches)) + for id, patch := range patches { + r.appearances[id] = resolvedAppearance{appearance: patch} + } +} + +func (r *Render) appearanceUnit(u unit.Unit) (unit.Unit, []unit.Unit, bool) { + patch, ok := r.appearances[u.Instance().Id()] + if !ok { + return u, nil, true + } + if !patch.compiled { + patch = compileAppearance(u, patch) + r.appearances[u.Instance().Id()] = patch + } + if !patch.visible { + return u, nil, false + } + main := u + if patch.sprite != nil { + main = main.WithSprite(patch.sprite, patch.pixelX, patch.pixelY, 0, 0, patch.pixelW, patch.pixelZ) + } + if patch.colorSet { + main = main.WithColor(patch.r, patch.g, patch.b, patch.a) + } + underlays := make([]unit.Unit, 0, len(patch.underlays)) + for _, sprite := range patch.underlays { + underlays = append(underlays, u.WithSprite(sprite, patch.pixelX, patch.pixelY, 0, 0, patch.pixelW, patch.pixelZ)) + } + return main, underlays, true +} + +func compileAppearance(u unit.Unit, patch resolvedAppearance) resolvedAppearance { + vars := u.Instance().Prefab().Vars() + icon, _ := vars.Text("icon") + state, _ := vars.Text("icon_state") + dir, _ := vars.Int("dir") + pixelX, _ := vars.Int("pixel_x") + pixelY, _ := vars.Int("pixel_y") + stepX, _ := vars.Int("step_x") + stepY, _ := vars.Int("step_y") + pixelW, _ := vars.Int("pixel_w") + pixelZ, _ := vars.Int("pixel_z") + appearance := patch.appearance.Appearance + patch.compiled, patch.visible = true, true + if appearance.Visible != nil && !*appearance.Visible { + patch.visible = false + return patch + } + if appearance.Icon != nil { + icon = *appearance.Icon + } + if appearance.IconState != nil { + state = *appearance.IconState + } + if appearance.Dir != nil { + dir = *appearance.Dir + } + if appearance.PixelX != nil { + pixelX = *appearance.PixelX + } + if appearance.PixelY != nil { + pixelY = *appearance.PixelY + } + if appearance.PixelW != nil { + pixelW = *appearance.PixelW + } + if appearance.PixelZ != nil { + pixelZ = *appearance.PixelZ + } + if dmi, err := dmicon.Cache.Get(icon); err == nil { + if dmiState, found := dmi.States[state]; found { + patch.sprite = dmiState.SpriteV(dir) + } + } + patch.pixelX, patch.pixelY, patch.pixelW, patch.pixelZ = pixelX+stepX, pixelY+stepY, pixelW, pixelZ + rc, gc, bc, ac := u.R(), u.G(), u.B(), u.A() + if appearance.Color != nil { + rc, gc, bc, _ = util.ParseColor(*appearance.Color).RGBA() + } + if appearance.Alpha != nil { + ac = *appearance.Alpha / 255 + } + patch.r, patch.g, patch.b, patch.a, patch.colorSet = rc, gc, bc, ac, appearance.Color != nil || appearance.Alpha != nil + patch.underlays = make([]*dmicon.Sprite, 0, len(patch.appearance.Underlays)) + for _, underlay := range patch.appearance.Underlays { + underIcon, underState := icon, state + underDir := dir + if underlay.Icon != nil { + underIcon = *underlay.Icon + } + if underlay.IconState != nil { + underState = *underlay.IconState + } + if underlay.Dir != nil { + underDir = *underlay.Dir + } + if underlay.Visible != nil && !*underlay.Visible { + continue + } + if sprite, err := dmicon.Cache.GetSpriteV(underIcon, underState, underDir); err == nil { + patch.underlays = append(patch.underlays, sprite) + } + } + return patch +} diff --git a/internal/app/render/bucket.go b/internal/app/render/bucket.go index 1759fc2d..2828d46f 100644 --- a/internal/app/render/bucket.go +++ b/internal/app/render/bucket.go @@ -74,16 +74,23 @@ func (r *Render) batchLevel(level int, viewBounds util.Bounds) { if !u.ViewBounds().ContainsV(viewBounds) { continue } - // Process unit - if r.unitProcessor != nil && !r.unitProcessor.ProcessUnit(u) { + effective, underlays, visible := r.appearanceUnit(u) + if !visible { continue } + // Process the effective unit so we get the same one + if r.unitProcessor != nil && !r.unitProcessor.ProcessUnit(effective) { + continue + } + for _, underlay := range underlays { + brush.RectTexturedV(underlay.ViewBounds().X1, underlay.ViewBounds().Y1, underlay.ViewBounds().X2, underlay.ViewBounds().Y2, underlay.R(), underlay.G(), underlay.B(), underlay.A(), underlay.Sprite().Texture(), underlay.Sprite().U1, underlay.Sprite().V1, underlay.Sprite().U2, underlay.Sprite().V2) + } brush.RectTexturedV( - u.ViewBounds().X1, u.ViewBounds().Y1, u.ViewBounds().X2, u.ViewBounds().Y2, - u.R(), u.G(), u.B(), u.A(), - u.Sprite().Texture(), - u.Sprite().U1, u.Sprite().V1, u.Sprite().U2, u.Sprite().V2, + effective.ViewBounds().X1, effective.ViewBounds().Y1, effective.ViewBounds().X2, effective.ViewBounds().Y2, + effective.R(), effective.G(), effective.B(), effective.A(), + effective.Sprite().Texture(), + effective.Sprite().U1, effective.Sprite().V1, effective.Sprite().U2, effective.Sprite().V2, ) } diff --git a/internal/app/render/bucket/level/chunk/unit/unit.go b/internal/app/render/bucket/level/chunk/unit/unit.go index b6136ac8..5d7fbedc 100644 --- a/internal/app/render/bucket/level/chunk/unit/unit.go +++ b/internal/app/render/bucket/level/chunk/unit/unit.go @@ -12,6 +12,8 @@ import ( type Unit struct { sprite *dmicon.Sprite instance *dmminstance.Instance + x, y int + iconSize int layer float32 viewBounds util.Bounds @@ -19,6 +21,21 @@ type Unit struct { r, g, b, a float32 } +// WithSprite returns a copy with a replacement appearance while retaining the +// same map instance. It is used by extension appearance patches for extension-driven updates :D +func (u Unit) WithSprite(sprite *dmicon.Sprite, pixelX, pixelY, stepX, stepY, pixelW, pixelZ int) Unit { + u.sprite = sprite + x1 := float32((u.x-1)*u.iconSize + pixelX + stepX + pixelW) + y1 := float32((u.y-1)*u.iconSize + pixelY + stepY + pixelZ) + u.viewBounds = util.Bounds{X1: x1, Y1: y1, X2: x1 + float32(sprite.IconWidth()), Y2: y1 + float32(sprite.IconHeight())} + return u +} + +func (u Unit) WithColor(r, g, b, a float32) Unit { + u.r, u.g, u.b, u.a = r, g, b, a + return u +} + func (u Unit) Sprite() *dmicon.Sprite { return u.sprite } @@ -71,7 +88,7 @@ func Make(x, y int, i *dmminstance.Instance, iconSize int) Unit { r, g, b, a := parseColor(i.Prefab()) return Unit{ - sp, i, countLayer(i.Prefab()), + sp, i, x, y, iconSize, countLayer(i.Prefab()), util.Bounds{X1: x1, Y1: y1, X2: x2, Y2: y2}, r, g, b, a, } diff --git a/internal/app/render/render.go b/internal/app/render/render.go index e71fa5fc..b546ef3f 100644 --- a/internal/app/render/render.go +++ b/internal/app/render/render.go @@ -17,6 +17,7 @@ type Render struct { overlay overlay unitProcessor unitProcessor renderGraph []graphNode + appearances map[uint64]resolvedAppearance } func New() *Render { @@ -25,6 +26,7 @@ func New() *Render { Camera: newCamera(), bucket: bucket.New(), renderGraph: compileRenderGraph(nil), + appearances: make(map[uint64]resolvedAppearance), } } From c4662ddcc1d38f4cba0014c4286483b8b08609b5 Mon Sep 17 00:00:00 2001 From: CabinetOnFire <> Date: Wed, 5 Aug 2026 15:45:45 +0200 Subject: [PATCH 09/11] Adds support for overlays and for level-targets / frame buffers (for z-level composition) --- extensionapi/extensionapi.go | 15 +++--- internal/app/extensions/manager.go | 40 ++++++++++++--- internal/app/render/appearance.go | 51 +++++++++++++++++-- internal/app/render/bucket.go | 66 +++++++++++++++++++++++-- internal/app/render/extension_passes.go | 17 +++++++ internal/app/render/level_target.go | 31 ++++++++++++ internal/app/render/render.go | 4 +- internal/dmapi/dmmap/dmm.go | 19 ++++--- 8 files changed, 212 insertions(+), 31 deletions(-) create mode 100644 internal/app/render/level_target.go diff --git a/extensionapi/extensionapi.go b/extensionapi/extensionapi.go index 0612d5ee..0e9c6014 100644 --- a/extensionapi/extensionapi.go +++ b/extensionapi/extensionapi.go @@ -21,9 +21,10 @@ const ( type Color struct{ R, G, B float32 } type AtomDefinition struct { - ID uint32 - Path string - Vars map[string]string + ID uint32 + Path string + Vars map[string]string + ResolvedVars map[string]string } // AtomInstance identifies one map atom. DefinitionID describes its resolved @@ -52,9 +53,10 @@ type Mesh struct { Quads []BilinearQuad } type RenderCommand struct { - ID string - Kind string - Mesh Mesh + ID string + Kind string + Level int // 0 renders on every level; otherwise only this map Z level. + Mesh Mesh } type RenderPass struct { ID string @@ -90,6 +92,7 @@ type AppearancePatch struct { AtomID uint64 Appearance Appearance Underlays []Appearance + Overlays []Appearance } type AppearanceUpdate struct { Reset bool diff --git a/internal/app/extensions/manager.go b/internal/app/extensions/manager.go index c272e60b..52d39a46 100644 --- a/internal/app/extensions/manager.go +++ b/internal/app/extensions/manager.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "sort" + "strings" "time" api "github.com/SpaiR/StrongDMM-extension-api" @@ -179,7 +180,7 @@ func (m *Manager) ExecuteMenuAction(id string) { // ContextMenuActions returns dynamic actions contributed for one turf. func (m *Manager) ContextMenuActions(dmm *dmmap.Dmm, point util.Point) []ContextMenuAction { doc := m.document(dmm) - turf := m.contextTurf(dmm.GetTile(point)) + turf := m.contextTurf(dmm, dmm.GetTile(point)) actions := make([]ContextMenuAction, 0) for _, client := range m.clients { if !m.enabled(client) || !client.hasCapability(api.CapabilityContextMenu) { @@ -210,7 +211,7 @@ func (m *Manager) ExecuteContextMenuAction(dmm *dmmap.Dmm, point util.Point, id if !m.enabled(client) || !client.hasCapability(api.CapabilityContextMenu) || len(id) <= len(prefix) || id[:len(prefix)] != prefix { continue } - response, err := client.extension.Handle(api.Request{Version: api.ProtocolVersion, Type: "context.action", MapID: doc.id, Revision: doc.revision, Action: &api.ActionRequest{ID: id[len(prefix):], Turf: m.contextTurf(dmm.GetTile(point))}}) + response, err := client.extension.Handle(api.Request{Version: api.ProtocolVersion, Type: "context.action", MapID: doc.id, Revision: doc.revision, Action: &api.ActionRequest{ID: id[len(prefix):], Turf: m.contextTurf(dmm, dmm.GetTile(point))}}) if err != nil { log.Warn().Err(err).Str("extension", client.manifest.ID).Msg("extension context action failed") return @@ -480,6 +481,7 @@ func (m *Manager) extensionAppearances(doc *document) map[uint64]api.AppearanceP previous.AtomID = id previous.Appearance = mergeAppearance(previous.Appearance, patch.Appearance) previous.Underlays = append(previous.Underlays, patch.Underlays...) + previous.Overlays = append(previous.Overlays, patch.Overlays...) result[id] = previous } } @@ -560,7 +562,7 @@ func (m *Manager) mapData(doc *document, dmm *dmmap.Dmm, points []util.Point) ap } } for _, point := range points { - mapData.Tiles = append(mapData.Tiles, m.tile(doc, dmm.GetTile(point))) + mapData.Tiles = append(mapData.Tiles, m.tile(doc, dmm, dmm.GetTile(point))) } if full { for _, atom := range doc.atoms { @@ -576,16 +578,20 @@ func (m *Manager) update(doc *document, dmm *dmmap.Dmm, points []util.Point) api mapData := m.mapData(doc, dmm, points) return api.MapUpdate{Width: mapData.Width, Height: mapData.Height, Levels: mapData.Levels, Definitions: mapData.Definitions, Tiles: mapData.Tiles} } -func (m *Manager) tile(doc *document, tile *dmmap.Tile) api.Tile { +func (m *Manager) tile(doc *document, dmm *dmmap.Dmm, tile *dmmap.Tile) api.Tile { out := api.Tile{X: tile.Coord.X, Y: tile.Coord.Y, Z: tile.Coord.Z} for _, instance := range tile.Instances() { vars := make(map[string]string) + resolvedVars := make(map[string]string) prefabVars := instance.Prefab().Vars() keys := resolvedVariableNames(prefabVars) sort.Strings(keys) for _, key := range keys { if value, ok := prefabVars.Value(key); ok { vars[key] = value + if resolved := resolveTypeVariable(dmm, value); resolved != value { + resolvedVars[key] = resolved + } } } key := instance.Prefab().Path() @@ -597,7 +603,7 @@ func (m *Manager) tile(doc *document, tile *dmmap.Tile) api.Tile { id = doc.nextDefinition doc.nextDefinition++ doc.definitions[key] = id - atom := api.AtomDefinition{ID: id, Path: instance.Prefab().Path(), Vars: vars} + atom := api.AtomDefinition{ID: id, Path: instance.Prefab().Path(), Vars: vars, ResolvedVars: resolvedVars} doc.atoms[id] = atom doc.newDefinitions = append(doc.newDefinitions, atom) out.Atoms = append(out.Atoms, api.AtomInstance{ID: instance.Id(), DefinitionID: id}) @@ -607,21 +613,41 @@ func (m *Manager) tile(doc *document, tile *dmmap.Tile) api.Tile { } return out } -func (m *Manager) contextTurf(tile *dmmap.Tile) api.ContextTurf { +func (m *Manager) contextTurf(dmm *dmmap.Dmm, tile *dmmap.Tile) api.ContextTurf { turf := api.ContextTurf{X: tile.Coord.X, Y: tile.Coord.Y, Z: tile.Coord.Z} for _, instance := range tile.Instances() { vars := make(map[string]string) + resolvedVars := make(map[string]string) prefabVars := instance.Prefab().Vars() for _, key := range resolvedVariableNames(prefabVars) { if value, ok := prefabVars.Value(key); ok { vars[key] = value + if resolved := resolveTypeVariable(dmm, value); resolved != value { + resolvedVars[key] = resolved + } } } - turf.Atoms = append(turf.Atoms, api.AtomDefinition{Path: instance.Prefab().Path(), Vars: vars}) + turf.Atoms = append(turf.Atoms, api.AtomDefinition{Path: instance.Prefab().Path(), Vars: vars, ResolvedVars: resolvedVars}) } return turf } +// resolveTypeVariable follows a DM /type::variable reference for extension metadata. +func resolveTypeVariable(dmm *dmmap.Dmm, value string) string { + typePath, variableName, found := strings.Cut(value, "::") + if !found || dmm.Environment == nil { + return value + } + object := dmm.Environment.Objects[typePath] + if object == nil { + return value + } + if resolved, exists := object.Vars.Value(variableName); exists { + return resolved + } + return value +} + func resolvedVariableNames(vars *dmvars.Variables) []string { names := make(map[string]struct{}) for current := vars; current != nil; current = current.Parent() { diff --git a/internal/app/render/appearance.go b/internal/app/render/appearance.go index 7955e4d4..c0daafc5 100644 --- a/internal/app/render/appearance.go +++ b/internal/app/render/appearance.go @@ -18,6 +18,13 @@ type resolvedAppearance struct { r, g, b, a float32 colorSet bool underlays []*dmicon.Sprite + overlays []resolvedSprite +} + +// resolvedSprite is a cached extension contribution positioned relative to its atom. +type resolvedSprite struct { + sprite *dmicon.Sprite + pixelX, pixelY, pixelW, pixelZ int } func (r *Render) SetAppearancePatches(patches map[uint64]api.AppearancePatch) { @@ -27,17 +34,17 @@ func (r *Render) SetAppearancePatches(patches map[uint64]api.AppearancePatch) { } } -func (r *Render) appearanceUnit(u unit.Unit) (unit.Unit, []unit.Unit, bool) { +func (r *Render) appearanceUnit(u unit.Unit) (unit.Unit, []unit.Unit, []unit.Unit, bool) { patch, ok := r.appearances[u.Instance().Id()] if !ok { - return u, nil, true + return u, nil, nil, true } if !patch.compiled { patch = compileAppearance(u, patch) r.appearances[u.Instance().Id()] = patch } if !patch.visible { - return u, nil, false + return u, nil, nil, false } main := u if patch.sprite != nil { @@ -50,7 +57,11 @@ func (r *Render) appearanceUnit(u unit.Unit) (unit.Unit, []unit.Unit, bool) { for _, sprite := range patch.underlays { underlays = append(underlays, u.WithSprite(sprite, patch.pixelX, patch.pixelY, 0, 0, patch.pixelW, patch.pixelZ)) } - return main, underlays, true + overlays := make([]unit.Unit, 0, len(patch.overlays)) + for _, overlay := range patch.overlays { + overlays = append(overlays, u.WithSprite(overlay.sprite, overlay.pixelX, overlay.pixelY, 0, 0, overlay.pixelW, overlay.pixelZ)) + } + return main, underlays, overlays, true } func compileAppearance(u unit.Unit, patch resolvedAppearance) resolvedAppearance { @@ -125,5 +136,37 @@ func compileAppearance(u unit.Unit, patch resolvedAppearance) resolvedAppearance patch.underlays = append(patch.underlays, sprite) } } + patch.overlays = make([]resolvedSprite, 0, len(patch.appearance.Overlays)) + for _, overlay := range patch.appearance.Overlays { + overlayIcon, overlayState, overlayDir := icon, state, dir + overlayPixelX, overlayPixelY, overlayPixelW, overlayPixelZ := pixelX, pixelY, pixelW, pixelZ + if overlay.Icon != nil { + overlayIcon = *overlay.Icon + } + if overlay.IconState != nil { + overlayState = *overlay.IconState + } + if overlay.Dir != nil { + overlayDir = *overlay.Dir + } + if overlay.PixelX != nil { + overlayPixelX = *overlay.PixelX + } + if overlay.PixelY != nil { + overlayPixelY = *overlay.PixelY + } + if overlay.PixelW != nil { + overlayPixelW = *overlay.PixelW + } + if overlay.PixelZ != nil { + overlayPixelZ = *overlay.PixelZ + } + if overlay.Visible != nil && !*overlay.Visible { + continue + } + if sprite, err := dmicon.Cache.GetSpriteV(overlayIcon, overlayState, overlayDir); err == nil { + patch.overlays = append(patch.overlays, resolvedSprite{sprite: sprite, pixelX: overlayPixelX + stepX, pixelY: overlayPixelY + stepY, pixelW: overlayPixelW, pixelZ: overlayPixelZ}) + } + } return patch } diff --git a/internal/app/render/bucket.go b/internal/app/render/bucket.go index 2828d46f..53063ed4 100644 --- a/internal/app/render/bucket.go +++ b/internal/app/render/bucket.go @@ -4,6 +4,8 @@ import ( "sdmm/internal/app/render/brush" "sdmm/internal/app/render/bucket/level/chunk/unit" "sdmm/internal/util" + + "github.com/go-gl/gl/v3.3-core/gl" ) var ( @@ -16,17 +18,46 @@ type unitProcessor interface { ProcessUnit(unit.Unit) (visible bool) } -func (r *Render) batchBucketUnits(viewBounds util.Bounds) { +// drawBucketLevels renders each Z level and its level-scoped extension output together. +func (r *Render) drawBucketLevels(viewBounds util.Bounds, width, height float32) { if MultiZRendering && r.Camera.Level > 1 { + var destinationFramebuffer int32 + gl.GetIntegerv(gl.FRAMEBUFFER_BINDING, &destinationFramebuffer) for level := 1; level < r.Camera.Level; level++ { - r.batchLevel(level, viewBounds) // Draw everything below. + r.drawCompositeLevel(level, viewBounds, width, height, uint32(destinationFramebuffer)) } // Draw a "shadow" overlay to visually separate different levels. + gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(destinationFramebuffer)) + gl.Viewport(0, 0, int32(width), int32(height)) brush.RectFilled(viewBounds.X1, viewBounds.Y1, viewBounds.X2, viewBounds.Y2, multiZShadow) + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + r.drawCompositeLevel(r.Camera.Level, viewBounds, width, height, uint32(destinationFramebuffer)) + return } - r.batchLevel(r.Camera.Level, viewBounds) // Draw currently visible level. + r.drawBucketLevel(r.Camera.Level, viewBounds, width, height) +} + +// drawCompositeLevel lights one Z level off-screen before alpha-compositing it. +func (r *Render) drawCompositeLevel(level int, viewBounds util.Bounds, width, height float32, destinationFramebuffer uint32) { + r.levelTarget.bind(int32(width), int32(height)) + gl.ClearColor(0, 0, 0, 0) + gl.Clear(gl.COLOR_BUFFER_BIT) + r.drawBucketLevel(level, viewBounds, width, height) + gl.BindFramebuffer(gl.FRAMEBUFFER, destinationFramebuffer) + gl.Viewport(0, 0, int32(width), int32(height)) + r.setNormalBlend() + brush.RectTexturedV(viewBounds.X1, viewBounds.Y1, viewBounds.X2, viewBounds.Y2, 1, 1, 1, 1, r.levelTarget.texture, 0, 1, 1, 0) + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) +} + +// drawBucketLevel draws one map level before applying its scoped extension commands. +func (r *Render) drawBucketLevel(level int, viewBounds util.Bounds, width, height float32) { + r.batchLevel(level, viewBounds) + r.batchLevelOverlays(level, viewBounds) + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + r.drawLevelExtensionPasses(level, width, height) } // batchUnitHighlights batches the active level's selected-unit highlights. @@ -74,7 +105,7 @@ func (r *Render) batchLevel(level int, viewBounds util.Bounds) { if !u.ViewBounds().ContainsV(viewBounds) { continue } - effective, underlays, visible := r.appearanceUnit(u) + effective, underlays, _, visible := r.appearanceUnit(u) if !visible { continue } @@ -98,6 +129,33 @@ func (r *Render) batchLevel(level int, viewBounds util.Bounds) { } } +// batchLevelOverlays draws extension overlays after all normal map atoms. +func (r *Render) batchLevelOverlays(level int, viewBounds util.Bounds) { + visibleLevel := r.bucket.Level(level) + if visibleLevel == nil { + return + } + for _, layer := range visibleLevel.Layers { + for _, chunk := range visibleLevel.ChunksByLayers[layer] { + if !chunk.ViewBounds.ContainsV(viewBounds) { + continue + } + for _, u := range chunk.UnitsByLayers[layer] { + if !u.ViewBounds().ContainsV(viewBounds) { + continue + } + _, _, overlays, visible := r.appearanceUnit(u) + if !visible { + continue + } + for _, overlay := range overlays { + brush.RectTexturedV(overlay.ViewBounds().X1, overlay.ViewBounds().Y1, overlay.ViewBounds().X2, overlay.ViewBounds().Y2, overlay.R(), overlay.G(), overlay.B(), overlay.A(), overlay.Sprite().Texture(), overlay.Sprite().U1, overlay.Sprite().V1, overlay.Sprite().U2, overlay.Sprite().V2) + } + } + } + } +} + func (r *Render) batchUnitHighlight(u unit.Unit) { if r.overlay == nil { return diff --git a/internal/app/render/extension_passes.go b/internal/app/render/extension_passes.go index 9ea6313b..79edf4db 100644 --- a/internal/app/render/extension_passes.go +++ b/internal/app/render/extension_passes.go @@ -114,6 +114,20 @@ func fallbackRenderGraph(nodes []graphNode) []graphNode { } func (r *Render) drawExtensionPass(pass api.RenderPass, width, height float32) { + r.drawExtensionPassLevel(pass, 0, width, height) +} + +// drawLevelExtensionPasses draws commands whose Level matches one map Z level. +func (r *Render) drawLevelExtensionPasses(level int, width, height float32) { + for _, node := range r.renderGraph { + if node.pass != nil { + r.drawExtensionPassLevel(*node.pass, level, width, height) + } + } +} + +// drawExtensionPassLevel draws either global commands (level 0) or one scoped Z level. +func (r *Render) drawExtensionPassLevel(pass api.RenderPass, level int, width, height float32) { switch pass.Blend { case "multiply": r.setMultiplyBlend() @@ -123,6 +137,9 @@ func (r *Render) drawExtensionPass(pass api.RenderPass, width, height float32) { r.setNormalBlend() } for _, command := range pass.Commands { + if command.Level != level { + continue + } switch command.Kind { case "mesh": vertices := make([]brush.Vertex, 0, len(command.Mesh.Vertices)) diff --git a/internal/app/render/level_target.go b/internal/app/render/level_target.go new file mode 100644 index 00000000..65c9f2e4 --- /dev/null +++ b/internal/app/render/level_target.go @@ -0,0 +1,31 @@ +package render + +import "github.com/go-gl/gl/v3.3-core/gl" + +// levelTarget stores one transparent framebuffer used to compose a map Z level. +type levelTarget struct { + framebuffer uint32 + texture uint32 + width int32 + height int32 +} + +// bind creates or resizes the target and makes it the current render destination. +func (target *levelTarget) bind(width, height int32) { + if target.framebuffer == 0 { + gl.GenFramebuffers(1, &target.framebuffer) + gl.GenTextures(1, &target.texture) + } + if target.width != width || target.height != height { + target.width, target.height = width, height + gl.BindTexture(gl.TEXTURE_2D, target.texture) + gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + } + gl.BindFramebuffer(gl.FRAMEBUFFER, target.framebuffer) + gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, target.texture, 0) + gl.Viewport(0, 0, width, height) +} diff --git a/internal/app/render/render.go b/internal/app/render/render.go index b546ef3f..7f648d51 100644 --- a/internal/app/render/render.go +++ b/internal/app/render/render.go @@ -18,6 +18,7 @@ type Render struct { unitProcessor unitProcessor renderGraph []graphNode appearances map[uint64]resolvedAppearance + levelTarget levelTarget } func New() *Render { @@ -78,8 +79,7 @@ func (r *Render) draw(width, height float32) { for _, node := range r.renderGraph { switch node.id { case coreMap: - r.batchBucketUnits(view) - brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + r.drawBucketLevels(view, width, height) case coreSelection: r.batchUnitHighlights(view) brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) diff --git a/internal/dmapi/dmmap/dmm.go b/internal/dmapi/dmmap/dmm.go index d4a9d8a8..6867889b 100644 --- a/internal/dmapi/dmmap/dmm.go +++ b/internal/dmapi/dmmap/dmm.go @@ -15,8 +15,9 @@ import ( // Dmm stores information about the map. // Unlike the dmmdata.DmmData this information is needed mostly for the editor usages. type Dmm struct { - Name string - Path DmmPath + Name string + Path DmmPath + Environment *dmenv.Dme Tiles []*Tile @@ -29,6 +30,7 @@ func (d *Dmm) Copy() Dmm { dmm := Dmm{} dmm.Name = d.Name dmm.Path = d.Path + dmm.Environment = d.Environment dmm.MaxX = d.MaxX dmm.MaxY = d.MaxY dmm.MaxZ = d.MaxZ @@ -107,12 +109,13 @@ func tileIndex(maxX, maxY, x, y, z int) int { func New(dme *dmenv.Dme, data *dmmdata.DmmData, backup string) (dmm *Dmm, unknownPrefabs map[string]*dmmprefab.Prefab) { unknownPrefabs = make(map[string]*dmmprefab.Prefab) dmm = &Dmm{ - Name: filepath.Base(data.Filepath), - Path: newDmmPath(dme.RootDir, data), - Tiles: make([]*Tile, data.MaxX*data.MaxY*data.MaxZ), - MaxX: data.MaxX, - MaxY: data.MaxY, - MaxZ: data.MaxZ, + Name: filepath.Base(data.Filepath), + Path: newDmmPath(dme.RootDir, data), + Environment: dme, + Tiles: make([]*Tile, data.MaxX*data.MaxY*data.MaxZ), + MaxX: data.MaxX, + MaxY: data.MaxY, + MaxZ: data.MaxZ, Backup: backup, } From 41aeb4ba18c585619e5a38ba57645e9780b50653 Mon Sep 17 00:00:00 2001 From: CabinetOnFire <> Date: Thu, 6 Aug 2026 20:27:57 +0200 Subject: [PATCH 10/11] adds notifyextension to fix issue where wall on top of wall would not be caught --- internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go | 5 +++++ internal/app/ui/cpwsarea/wsmap/tools/add.go | 5 +++++ internal/app/ui/cpwsarea/wsmap/tools/fill.go | 3 +++ internal/app/ui/cpwsarea/wsmap/tools/tools.go | 1 + 4 files changed, 14 insertions(+) diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go b/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go index 1966329f..8a773946 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go @@ -115,6 +115,11 @@ func (e *Editor) UpdateCanvasByCoords(coords []util.Point) { e.pMap.Canvas().Render().UpdateBucketV(e.dmm, e.pMap.ActiveLevel(), coords) } +// NotifyExtensions forwards visual changes that do not change prefab contents, such as replacing an instance with another instance of the same prefab. +func (e *Editor) NotifyExtensions(coords []util.Point) { + e.pMap.NotifyExtensions(coords) +} + // UpdateCanvasByTiles updates the canvas for the provided tiles. func (e *Editor) UpdateCanvasByTiles(tiles []dmmap.Tile) { coords := make([]util.Point, 0, len(tiles)) diff --git a/internal/app/ui/cpwsarea/wsmap/tools/add.go b/internal/app/ui/cpwsarea/wsmap/tools/add.go index 521a057e..e1481df1 100644 --- a/internal/app/ui/cpwsarea/wsmap/tools/add.go +++ b/internal/app/ui/cpwsarea/wsmap/tools/add.go @@ -54,7 +54,12 @@ func (t *ToolAdd) onMove(coord util.Point) { func (t *ToolAdd) onStop(util.Point) { if len(t.editedTiles) != 0 { + editedPoints := make([]util.Point, 0, len(t.editedTiles)) + for coord := range t.editedTiles { + editedPoints = append(editedPoints, coord) + } t.editedTiles = make(map[util.Point]bool, len(t.editedTiles)) + ed.NotifyExtensions(editedPoints) go ed.CommitChanges("Add Atoms") } } diff --git a/internal/app/ui/cpwsarea/wsmap/tools/fill.go b/internal/app/ui/cpwsarea/wsmap/tools/fill.go index b831eb1f..61d36f6a 100644 --- a/internal/app/ui/cpwsarea/wsmap/tools/fill.go +++ b/internal/app/ui/cpwsarea/wsmap/tools/fill.go @@ -72,10 +72,12 @@ func (t *ToolFill) onStop(util.Point) { // Fill the area. if prefab, ok := ed.SelectedPrefab(); ok { + var filledPoints []util.Point fillTile := func(x, y int) { coord := util.Point{X: x, Y: y, Z: t.start.Z} tile := ed.Dmm().GetTile(coord) t.basicPrefabAdd(tile, prefab) + filledPoints = append(filledPoints, coord) } if imguiext.IsCtrlDown() { for x := t.fillArea.X1; x <= t.fillArea.X2; x++ { @@ -94,6 +96,7 @@ func (t *ToolFill) onStop(util.Point) { } } + ed.NotifyExtensions(filledPoints) go ed.CommitChanges("Fill Atoms") } diff --git a/internal/app/ui/cpwsarea/wsmap/tools/tools.go b/internal/app/ui/cpwsarea/wsmap/tools/tools.go index 61b38774..ed1eab47 100644 --- a/internal/app/ui/cpwsarea/wsmap/tools/tools.go +++ b/internal/app/ui/cpwsarea/wsmap/tools/tools.go @@ -44,6 +44,7 @@ type editor interface { Dmm() *dmmap.Dmm CommitChanges(commitMsg string) + NotifyExtensions([]util.Point) UpdateCanvasByCoords([]util.Point) UpdateCanvasByTiles([]dmmap.Tile) From 70caa8a373aa02e10bbdc2efad0d2a02d985d5f6 Mon Sep 17 00:00:00 2001 From: CabinetOnFire <> Date: Tue, 18 Aug 2026 16:10:20 +0200 Subject: [PATCH 11/11] removes unsafe pointers cuz they kept crashing my ass --- internal/app/render/brush/bilinear.go | 2 +- internal/platform/gl.go | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/internal/app/render/brush/bilinear.go b/internal/app/render/brush/bilinear.go index 5ecdba4b..7efea237 100644 --- a/internal/app/render/brush/bilinear.go +++ b/internal/app/render/brush/bilinear.go @@ -40,7 +40,7 @@ func BilinearQuads(quads []BilinearQuad, w, h, x, y, z, colorFloor float32) { gl.BufferData(gl.ARRAY_BUFFER, len(data)*platform.FloatSize, gl.Ptr(data), gl.STREAM_DRAW) gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, bilinearEBO) gl.BufferData(gl.ELEMENT_ARRAY_BUFFER, len(indices)*4, gl.Ptr(indices), gl.STREAM_DRAW) - gl.DrawElements(gl.TRIANGLES, int32(len(indices)), gl.UNSIGNED_INT, gl.PtrOffset(0)) + gl.DrawElementsWithOffset(gl.TRIANGLES, int32(len(indices)), gl.UNSIGNED_INT, 0) gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, 0) gl.BindBuffer(gl.ARRAY_BUFFER, 0) gl.BindVertexArray(0) diff --git a/internal/platform/gl.go b/internal/platform/gl.go index d34ed43f..72faf775 100644 --- a/internal/platform/gl.go +++ b/internal/platform/gl.go @@ -1,8 +1,6 @@ package platform import ( - "unsafe" - "github.com/SpaiR/imgui-go" "github.com/go-gl/gl/v3.3-core/gl" "github.com/rs/zerolog/log" @@ -109,7 +107,7 @@ func Render(drawData imgui.DrawData) { } else if clipRectX < fbWidth && clipRectY < fbHeight && clipRectZ > 0 && clipRectW > 0 { gl.Scissor(int32(clipRectX), int32(fbHeight-clipRectW), int32(clipRectZ-clipRectX), int32(clipRectW-clipRectY)) gl.BindTexture(gl.TEXTURE_2D, uint32(cmd.TextureID())) - gl.DrawElements(gl.TRIANGLES, int32(cmd.ElementCount()), uint32(gl.UNSIGNED_SHORT), unsafe.Pointer(indexBufferOffset)) + gl.DrawElementsWithOffset(gl.TRIANGLES, int32(cmd.ElementCount()), uint32(gl.UNSIGNED_SHORT), indexBufferOffset) } indexBufferOffset += uintptr(cmd.ElementCount() * indexSize) @@ -285,9 +283,9 @@ func bind(displayPos, displaySize *imgui.Vec2, fbWidth, fbHeight int32) { gl.EnableVertexAttribArray(uint32(gAttributeLocationVtxColor)) vertexSize, vertexOffsetPos, vertexOffsetUv, vertexOffsetCol := imgui.VertexBufferLayout() - gl.VertexAttribPointer(uint32(gAttributeLocationVtxPos), 2, gl.FLOAT, false, int32(vertexSize), unsafe.Pointer(uintptr(vertexOffsetPos))) - gl.VertexAttribPointer(uint32(gAttributeLocationVtxUV), 2, gl.FLOAT, false, int32(vertexSize), unsafe.Pointer(uintptr(vertexOffsetUv))) - gl.VertexAttribPointer(uint32(gAttributeLocationVtxColor), 4, gl.UNSIGNED_BYTE, true, int32(vertexSize), unsafe.Pointer(uintptr(vertexOffsetCol))) + gl.VertexAttribPointerWithOffset(uint32(gAttributeLocationVtxPos), 2, gl.FLOAT, false, int32(vertexSize), uintptr(vertexOffsetPos)) + gl.VertexAttribPointerWithOffset(uint32(gAttributeLocationVtxUV), 2, gl.FLOAT, false, int32(vertexSize), uintptr(vertexOffsetUv)) + gl.VertexAttribPointerWithOffset(uint32(gAttributeLocationVtxColor), 4, gl.UNSIGNED_BYTE, true, int32(vertexSize), uintptr(vertexOffsetCol)) } func unbind() {