From 2b9eb1e2ad58aa44abf33514a7e31bd53cf44294 Mon Sep 17 00:00:00 2001 From: mathieu17g Date: Wed, 7 May 2025 11:52:48 +0200 Subject: [PATCH 01/59] Memoization on `typemap` function: 30x speedup seen --- src/KML.jl | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/KML.jl b/src/KML.jl index afef697..5b4ae99 100644 --- a/src/KML.jl +++ b/src/KML.jl @@ -79,9 +79,19 @@ end XML.children(o::KMLElement) = XML.children(Node(o)) typemap(o) = typemap(typeof(o)) +#! WIP +# 1. cache: key = the struct type (e.g. Placemark), value = Dict of field ⇒ Type +const _FIELD_MAP_CACHE = IdDict{DataType,Dict{Symbol,Type}}() +# 2. fast typemap using the cache function typemap(::Type{T}) where {T<:KMLElement} - Dict(name => Base.nonnothingtype(S) for (name, S) in zip(fieldnames(T), fieldtypes(T))) + get!(_FIELD_MAP_CACHE, T) do + Dict{Symbol,Type}(name => Base.nonnothingtype(S) for (name, S) in zip(fieldnames(T), fieldtypes(T))) + end end +#! END WIP +#// function typemap(::Type{T}) where {T<:KMLElement} +#// Dict(name => Base.nonnothingtype(S) for (name, S) in zip(fieldnames(T), fieldtypes(T))) +#// end Base.:(==)(a::T, b::T) where {T<:KMLElement} = all(getfield(a,f) == getfield(b,f) for f in fieldnames(T)) @@ -99,13 +109,13 @@ Base.string(o::AbstractKMLEnum) = o.value macro kml_enum(T, vals...) esc(quote - struct $T <: AbstractKMLEnum - value::String - function $T(value) + struct $T <: AbstractKMLEnum + value::String + function $T(value) string(value) ∈ $(string.(vals)) || error($(string(T)) * " ∉ " * join($vals, ", ") * ". Found: " * string(value)) - new(string(value)) + new(string(value)) + end end - end end) end @kml_enum altitudeMode clampToGround relativeToGround absolute From dbd21528d87652ac4fe3b5130d77abb2a7af216f Mon Sep 17 00:00:00 2001 From: mathieu17g Date: Wed, 7 May 2025 17:50:29 +0200 Subject: [PATCH 02/59] =?UTF-8?q?Add=20a=20fast=E2=80=91path=20for=20simpl?= =?UTF-8?q?e=20leaf=20tags=20in=20`add=5Felement!`.=2033%=20speed=20gain?= =?UTF-8?q?=20seen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parsing.jl | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/parsing.jl b/src/parsing.jl index 98a6a51..310ce4e 100644 --- a/src/parsing.jl +++ b/src/parsing.jl @@ -52,6 +52,40 @@ end function add_element!(o::Union{Object,KMLElement}, child::Node) sym = tagsym(child) + + # ────────────────────────────────────────────────────────────────────────────────────── + # 1. fast‑path for simple leaf tags (, , , etc.) + # ────────────────────────────────────────────────────────────────────────────────────── + if XML.is_simple(child) + fname = Symbol(replace(child.tag, ":" => "_")) + hasfield(typeof(o), fname) || return # parent has no such field + txt = XML.value(XML.only(child)) # the text content + ftype = typemap(typeof(o))[fname] # cached dict, O(1) + + if ftype === String + val = txt + elseif ftype <: Integer + val = parse(Int, txt) # id, visibility, etc. + elseif ftype <: AbstractFloat + val = parse(Float64, txt) # longitude, latitude, etc. + elseif ftype <: Bool + val = (txt == "1" || lowercase(txt) == "true") + elseif ftype <: Enum + val = ftype(txt) # altitudeMode, etc. + else # fallback (rare) + # complex or container type (Vector, Union of Vectors, etc.) + # → let the original logic handle it (includes coordinates parsing) + autosetfield!(o, fname, txt) + return + end + + setfield!(o, fname, val) + return + end + + # ────────────────────────────────────────────────────────────────────────────────────── + # 2. complex child → recurse + # ────────────────────────────────────────────────────────────────────────────────────── o_child = object(child) if !isnothing(o_child) From 5a6db75e95e0693245832530061b8ad9644d3ecc Mon Sep 17 00:00:00 2001 From: mathieu17g Date: Wed, 7 May 2025 19:50:29 +0200 Subject: [PATCH 03/59] Enhanced `object` function -> 40% speed gain on reading --- src/KML.jl | 38 ++++++++++++++++++++++++++++++-------- src/parsing.jl | 29 ++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/KML.jl b/src/KML.jl index 5b4ae99..fd2bf23 100644 --- a/src/KML.jl +++ b/src/KML.jl @@ -5,6 +5,12 @@ using GeoInterface: GeoInterface import XML: XML, Node using InteractiveUtils: subtypes +#-----------------------------------------------------------------------------# Memoization constants +# object types are stored as symbols in the TAG_TO_TYPE dictionary +const TAG_TO_TYPE = Dict{Symbol,DataType}() +# typemap cache: key = the struct type (e.g. Placemark), value = Dict of field ⇒ Type +const _FIELD_MAP_CACHE = IdDict{DataType,Dict{Symbol,Type}}() + #----------------------------------------------------------------------------# utils const INDENT = " " @@ -79,19 +85,12 @@ end XML.children(o::KMLElement) = XML.children(Node(o)) typemap(o) = typemap(typeof(o)) -#! WIP -# 1. cache: key = the struct type (e.g. Placemark), value = Dict of field ⇒ Type -const _FIELD_MAP_CACHE = IdDict{DataType,Dict{Symbol,Type}}() -# 2. fast typemap using the cache +# fast typemap using the cache function typemap(::Type{T}) where {T<:KMLElement} get!(_FIELD_MAP_CACHE, T) do Dict{Symbol,Type}(name => Base.nonnothingtype(S) for (name, S) in zip(fieldnames(T), fieldtypes(T))) end end -#! END WIP -#// function typemap(::Type{T}) where {T<:KMLElement} -#// Dict(name => Base.nonnothingtype(S) for (name, S) in zip(fieldnames(T), fieldtypes(T))) -#// end Base.:(==)(a::T, b::T) where {T<:KMLElement} = all(getfield(a,f) == getfield(b,f) for f in fieldnames(T)) @@ -736,4 +735,27 @@ for T in vcat(all_concrete_subtypes(KMLElement), all_abstract_subtypes(Object)) end end +#! WIP +# ------------------------------------------------------------------ +# Build TAG_TO_TYPE with *recursive* subtype walk +# ------------------------------------------------------------------ + +"Recursively collect every concrete descendant of `root`." +function _collect_concrete!(root) # accept *any* type value + for S in subtypes(root) + if isabstracttype(S) + _collect_concrete!(S) # recurse first + else + sym = Symbol(replace(string(S), "KML." => "")) # :Document, … + TAG_TO_TYPE[sym] = S + end + end + return +end + +function __init__() + _collect_concrete!(KMLElement) # helper is already defined +end +#! END WIP + end #module diff --git a/src/parsing.jl b/src/parsing.jl index 310ce4e..c7f93e1 100644 --- a/src/parsing.jl +++ b/src/parsing.jl @@ -33,7 +33,34 @@ function Node(o::T) where {names, T <: KMLElement{names}} end #-----------------------------------------------------------------------------# object (or enum) -function object(node::Node) +# Fast object() – deal with the handful of tags we care about +function object(node::XML.Node) + sym = tagsym(node) + + # 1. tags that map straight to KML types -------------------- + if haskey(TAG_TO_TYPE, sym) + T = TAG_TO_TYPE[sym] + o = T() # no reflection + add_attributes!(o, node) + for child in XML.children(node) + add_element!(o, child) + end + return o + end + # 2. enums --------------------------------------------------- + if sym in names(Enums, all = true) + return getproperty(Enums, sym)(XML.value(only(node))) + end + # 3. , , … fast scalar leafs ------------- + if XML.is_simple(node) + return String(XML.value(only(node))) # plain text + end + # 4. fallback to the old generic code ------------------------ + return _object_slow(node) +end + +# original implementation, renamed +_object_slow(node::XML.Node) = begin sym = tagsym(node) if sym in names(Enums, all=true) return getproperty(Enums, sym)(XML.value(only(node))) From 59963bc99ffe58ac7ae65f51d86a1e85b1f9ce7e Mon Sep 17 00:00:00 2001 From: mathieu17g Date: Wed, 7 May 2025 20:14:07 +0200 Subject: [PATCH 04/59] Cleaned code --- src/KML.jl | 34 +++++------- src/parsing.jl | 148 +++++++++++++++++++++++++------------------------ 2 files changed, 89 insertions(+), 93 deletions(-) diff --git a/src/KML.jl b/src/KML.jl index fd2bf23..8a637d8 100644 --- a/src/KML.jl +++ b/src/KML.jl @@ -722,24 +722,7 @@ Base.@kwdef mutable struct LookAt <: AbstractView @altitude_mode_elements end -#-----------------------------------------------------------------------------# parsing -include("parsing.jl") - -#-----------------------------------------------------------------------------# exports -export KMLFile, Enums, object - -for T in vcat(all_concrete_subtypes(KMLElement), all_abstract_subtypes(Object)) - if T != KML.Pair - e = Symbol(replace(string(T), "KML." => "")) - @eval export $e - end -end - -#! WIP -# ------------------------------------------------------------------ -# Build TAG_TO_TYPE with *recursive* subtype walk -# ------------------------------------------------------------------ - +#-----------------------------------------------------------------------------# build TAG_TO_TYPE "Recursively collect every concrete descendant of `root`." function _collect_concrete!(root) # accept *any* type value for S in subtypes(root) @@ -752,10 +735,19 @@ function _collect_concrete!(root) # accept *any* type value end return end +_collect_concrete!(KMLElement) + +#-----------------------------------------------------------------------------# parsing +include("parsing.jl") + +#-----------------------------------------------------------------------------# exports +export KMLFile, Enums, object -function __init__() - _collect_concrete!(KMLElement) # helper is already defined +for T in vcat(all_concrete_subtypes(KMLElement), all_abstract_subtypes(Object)) + if T != KML.Pair + e = Symbol(replace(string(T), "KML." => "")) + @eval export $e + end end -#! END WIP end #module diff --git a/src/parsing.jl b/src/parsing.jl index c7f93e1..e4aa0fe 100644 --- a/src/parsing.jl +++ b/src/parsing.jl @@ -77,76 +77,72 @@ _object_slow(node::XML.Node) = begin nothing end -function add_element!(o::Union{Object,KMLElement}, child::Node) - sym = tagsym(child) - - # ────────────────────────────────────────────────────────────────────────────────────── - # 1. fast‑path for simple leaf tags (, , , etc.) - # ────────────────────────────────────────────────────────────────────────────────────── - if XML.is_simple(child) - fname = Symbol(replace(child.tag, ":" => "_")) - hasfield(typeof(o), fname) || return # parent has no such field - txt = XML.value(XML.only(child)) # the text content - ftype = typemap(typeof(o))[fname] # cached dict, O(1) - - if ftype === String - val = txt +function add_element!(parent::Union{Object,KMLElement}, child::XML.Node) + # ── 0. pre‑compute a few things ─────────────────────────────── + fname = Symbol(replace(child.tag, ":" => "_")) # tag → field name + simple = XML.is_simple(child) + + # ── 1. *Scalar* leaf node (fast path) ───────────────────────── + if simple + hasfield(typeof(parent), fname) || return # ignore strangers + + txt = String(XML.value(XML.only(child))) # raw text + ftype = typemap(typeof(parent))[fname] # cached Dict + + # (a) the easy built‑ins + val = if ftype === String + txt elseif ftype <: Integer - val = parse(Int, txt) # id, visibility, etc. + txt == "" ? zero(ftype) : parse(ftype, txt) elseif ftype <: AbstractFloat - val = parse(Float64, txt) # longitude, latitude, etc. + txt == "" ? zero(ftype) : parse(ftype, txt) elseif ftype <: Bool - val = (txt == "1" || lowercase(txt) == "true") - elseif ftype <: Enum - val = ftype(txt) # altitudeMode, etc. - else # fallback (rare) - # complex or container type (Vector, Union of Vectors, etc.) - # → let the original logic handle it (includes coordinates parsing) - autosetfield!(o, fname, txt) - return + txt == "1" || lowercase(txt) == "true" + elseif ftype <: Enums.AbstractKMLEnum + ftype(txt) + # (b) the special coordinate string + elseif fname === :coordinates + vec = [Tuple(parse.(Float64, split(v, ','))) for v in split(txt)] + (ftype <: Union{Nothing,Tuple}) ? first(vec) : vec + # (c) fallback – let the generic helper take a stab + else + autosetfield!(parent, fname, txt); return end - setfield!(o, fname, val) + setfield!(parent, fname, val) return end - # ────────────────────────────────────────────────────────────────────────────────────── - # 2. complex child → recurse - # ────────────────────────────────────────────────────────────────────────────────────── - o_child = object(child) - - if !isnothing(o_child) - @goto child_is_object - else - @goto child_is_not_object - end - - @label child_is_not_object - return if sym == :outerBoundaryIs - setfield!(o, :outerBoundaryIs, object(XML.only(child))) - elseif sym == :innerBoundaryIs - setfield!(o, :innerBoundaryIs, object.(XML.children(child))) - elseif hasfield(typeof(o), sym) && XML.is_simple(child) - autosetfield!(o, sym, XML.value(only(child))) - else - @warn "Unhandled case encountered while trying to add child with tag `$sym` to parent `$o`." - end - - @label child_is_object - T = typeof(o_child) - - for (field, FT) in typemap(o) - T <: FT && return setfield!(o, field, o_child) - if FT <: AbstractVector && T <: eltype(FT) - v = getfield(o, field) - if isnothing(v) - setfield!(o, field, eltype(FT)[]) + # ── 2. complex child object – recurse ───────────────────────── + child_obj = object(child) + if child_obj !== nothing + # push it into the FIRST matching slot we find + T = typeof(child_obj) + for (field, FT) in typemap(parent) + if T <: FT + setfield!(parent, field, child_obj) + return + elseif FT <: AbstractVector && T <: eltype(FT) + vec = getfield(parent, field) + if vec === nothing + setfield!(parent, field, eltype(FT)[]) + vec = getfield(parent, field) + end + push!(vec, child_obj) + return end - push!(getfield(o, field), o_child) - return + end + error("Unhandled child type: $(T) for parent $(typeof(parent))") + else + # legacy edge‑cases (, , …) + if fname === :outerBoundaryIs + setfield!(parent, :outerBoundaryIs, object(XML.only(child))) + elseif fname === :innerBoundaryIs + setfield!(parent, :innerBoundaryIs, object.(XML.children(child))) + else + @warn "Unhandled tag $fname for $(typeof(parent))" end end - error("This was not handled: $o_child") end @@ -160,18 +156,26 @@ function add_attributes!(o::Union{Object,KMLElement}, source::Node) end end -function autosetfield!(o::Union{Object,KMLElement}, sym::Symbol, x::String) - T = typemap(o)[sym] - T <: Number && return setfield!(o, sym, parse(T, x)) - T <: AbstractString && return setfield!(o, sym, x) - T <: Enums.AbstractKMLEnum && return setfield!(o, sym, T(x)) - if sym == :coordinates - val = [Tuple(parse.(Float64, split(v, ','))) for v in split(x)] - # coordinates can be a tuple or a vector of tuples, so we need to do this: - if fieldtype(typeof(o), sym) <: Union{Nothing, Tuple} - val = val[1] - end - return setfield!(o, sym, val) +function autosetfield!(o::Union{Object,KMLElement}, sym::Symbol, txt::String) + ftype = typemap(o)[sym] + + val = if ftype <: AbstractString + txt + elseif ftype <: Integer + txt == "" ? zero(ftype) : parse(ftype, txt) + elseif ftype <: AbstractFloat + txt == "" ? zero(ftype) : parse(ftype, txt) + elseif ftype <: Bool + txt == "1" || lowercase(txt) == "true" + elseif ftype <: Enums.AbstractKMLEnum + ftype(txt) + elseif sym === :coordinates + vec = [Tuple(parse.(Float64, split(v, ','))) for v in split(txt)] + (ftype <: Union{Nothing,Tuple}) ? first(vec) : vec + else + txt # last‑ditch: store the raw string end - setfield!(o, sym, x) + + setfield!(o, sym, val) + return end From 4a29aa3ca43d365fb859615e1ea5bfdd0688cafe Mon Sep 17 00:00:00 2001 From: mathieu17g Date: Thu, 8 May 2025 10:00:12 +0200 Subject: [PATCH 05/59] Handle omitted comma after an altitude in XML file --- src/parsing.jl | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/parsing.jl b/src/parsing.jl index e4aa0fe..fcf0ca8 100644 --- a/src/parsing.jl +++ b/src/parsing.jl @@ -102,7 +102,10 @@ function add_element!(parent::Union{Object,KMLElement}, child::XML.Node) ftype(txt) # (b) the special coordinate string elseif fname === :coordinates - vec = [Tuple(parse.(Float64, split(v, ','))) for v in split(txt)] + # Many public KML files omit the comma after an altitude `0`, leaving only a + # space before the next longitude. Google Earth and GDAL accept this. See + # https://kml4earth.appspot.com/kmlErrata.html#validation + vec = [Tuple(parse.(Float64, split(v, r"[,\s]+"))) for v in split(txt)] (ftype <: Union{Nothing,Tuple}) ? first(vec) : vec # (c) fallback – let the generic helper take a stab else @@ -170,7 +173,10 @@ function autosetfield!(o::Union{Object,KMLElement}, sym::Symbol, txt::String) elseif ftype <: Enums.AbstractKMLEnum ftype(txt) elseif sym === :coordinates - vec = [Tuple(parse.(Float64, split(v, ','))) for v in split(txt)] + # Many public KML files omit the comma after an altitude `0`, leaving only a + # space before the next longitude. Google Earth and GDAL accept this. See + # https://kml4earth.appspot.com/kmlErrata.html#validation + vec = [Tuple(parse.(Float64, split(v, r"[,\s]+"))) for v in split(txt)] (ftype <: Union{Nothing,Tuple}) ? first(vec) : vec else txt # last‑ditch: store the raw string From 3b460342a5ea0b7089ebfc3a252498893145fba1 Mon Sep 17 00:00:00 2001 From: mathieu17g Date: Fri, 9 May 2025 08:08:22 +0200 Subject: [PATCH 06/59] Several fixes: - Enhance coordinate parsing - Skip xmlns declaration when adding attributes - Ignore `typemap` unknown attributes when adding attributes - Enhance GeoInterface part (WIP: should probably be moved to a dedicated file and further developped) --- src/KML.jl | 106 +++++++++++++++++++++++++++++-------------------- src/parsing.jl | 58 ++++++++++++++++----------- 2 files changed, 98 insertions(+), 66 deletions(-) diff --git a/src/KML.jl b/src/KML.jl index 8a637d8..a665ec3 100644 --- a/src/KML.jl +++ b/src/KML.jl @@ -23,8 +23,8 @@ macro def(name, definition) end @def altitude_mode_elements begin - altitudeMode::Union{Nothing, Enums.altitudeMode} = nothing - gx_altitudeMode::Union{Nothing, Enums.gx_altitudeMode} = nothing + altitudeMode::Union{Nothing,Enums.altitudeMode} = nothing + gx_altitudeMode::Union{Nothing,Enums.gx_altitudeMode} = nothing end # @option field::Type → field::Union{Nothing, Type} = nothing @@ -68,8 +68,8 @@ abstract type KMLElement{attr_names} <: XML.AbstractXMLNode end const NoAttributes = KMLElement{()} -function Base.show(io::IO, o::T) where {names, T <: KMLElement{names}} - printstyled(io, T; color=:light_cyan) +function Base.show(io::IO, o::T) where {names,T<:KMLElement{names}} + printstyled(io, T; color = :light_cyan) print(io, ": [") show(io, Node(o)) print(io, ']') @@ -78,7 +78,7 @@ end # XML Interface XML.tag(o::KMLElement) = name(o) -function XML.attributes(o::T) where {names, T <: KMLElement{names}} +function XML.attributes(o::T) where {names,T<:KMLElement{names}} OrderedDict(k => getfield(o, k) for k in names if !isnothing(getfield(o, k))) end @@ -92,7 +92,7 @@ function typemap(::Type{T}) where {T<:KMLElement} end end -Base.:(==)(a::T, b::T) where {T<:KMLElement} = all(getfield(a,f) == getfield(b,f) for f in fieldnames(T)) +Base.:(==)(a::T, b::T) where {T<:KMLElement} = all(getfield(a, f) == getfield(b, f) for f in fieldnames(T)) #-----------------------------------------------------------------------------# "Enums" @@ -107,15 +107,18 @@ Base.convert(::Type{T}, x::String) where {T<:AbstractKMLEnum} = T(x) Base.string(o::AbstractKMLEnum) = o.value macro kml_enum(T, vals...) - esc(quote + esc( + quote struct $T <: AbstractKMLEnum value::String function $T(value) - string(value) ∈ $(string.(vals)) || error($(string(T)) * " ∉ " * join($vals, ", ") * ". Found: " * string(value)) + string(value) ∈ $(string.(vals)) || + error($(string(T)) * " ∉ " * join($vals, ", ") * ". Found: " * string(value)) new(string(value)) end end - end) + end, + ) end @kml_enum altitudeMode clampToGround relativeToGround absolute @kml_enum gx_altitudeMode relativeToSeaFloor clampToSeaFloor @@ -134,28 +137,28 @@ end #-----------------------------------------------------------------------------# KMLFile mutable struct KMLFile - children::Vector{Union{Node, KMLElement}} # Union with XML.Node to allow Comment and CData + children::Vector{Union{Node,KMLElement}} # Union with XML.Node to allow Comment and CData end KMLFile(content::KMLElement...) = KMLFile(collect(content)) -Base.push!(o::KMLFile, el::Union{Node, KMLElement}) = push!(o.children, el) +Base.push!(o::KMLFile, el::Union{Node,KMLElement}) = push!(o.children, el) # TODO: print better summary of file function Base.show(io::IO, o::KMLFile) print(io, "KMLFile ") - printstyled(io, '(', Base.format_bytes(Base.summarysize(o)), ')'; color=:light_black) + printstyled(io, '(', Base.format_bytes(Base.summarysize(o)), ')'; color = :light_black) end function Node(o::KMLFile) children = [ Node(XML.Declaration, nothing, OrderedDict("version" => "1.0", "encoding" => "UTF-8")), - Node(XML.Element, "kml", OrderedDict("xmlns" => "http://earth.google.com/kml/2.2"), nothing, Node.(o.children)) + Node(XML.Element, "kml", OrderedDict("xmlns" => "http://earth.google.com/kml/2.2"), nothing, Node.(o.children)), ] Node(XML.Document, nothing, nothing, nothing, children) end -Base.:(==)(a::KMLFile, b::KMLFile) = all(getfield(a,f) == getfield(b,f) for f in fieldnames(KMLFile)) +Base.:(==)(a::KMLFile, b::KMLFile) = all(getfield(a, f) == getfield(b, f) for f in fieldnames(KMLFile)) # read Base.read(io::IO, ::Type{KMLFile}) = KMLFile(read(io, XML.Node)) @@ -168,7 +171,7 @@ end Base.parse(::Type{KMLFile}, s::AbstractString) = KMLFile(XML.parse(s, Node)) -Writable = Union{KMLFile, KMLElement, Node} +Writable = Union{KMLFile,KMLElement,Node} write(io::IO, o::Writable; kw...) = XML.write(io, Node(o); kw...) write(file::AbstractString, o::Writable; kw...) = XML.write(file, Node(o); kw...) @@ -182,7 +185,9 @@ abstract type Overlay <: Feature end abstract type Container <: Feature end abstract type Geometry <: Object end -GeoInterface.isgeometry(o::Geometry) = true +GeoInterface.isgeometry(::Geometry) = true +GeoInterface.isgeometry(::Type{<:Geometry}) = true +GeoInterface.crs(::Geometry) = GeoInterface.default_crs() abstract type StyleSelector <: Object end @@ -278,13 +283,13 @@ end #-----------------------------------------------------------------------------# Region <: Object Base.@kwdef mutable struct Region <: Object @object - LatLonAltBox::LatLonAltBox = LatLonAltBox(north=0,south=0,east=0,west=0) + LatLonAltBox::LatLonAltBox = LatLonAltBox(north = 0, south = 0, east = 0, west = 0) @option Lod::Lod end #-----------------------------------------------------------------------------# gx_LatLonQuad <: Object Base.@kwdef mutable struct gx_LatLonQuad <: Object @object - coordinates::Vector{NTuple{2, Float64}} = [(0,0), (0,0), (0,0), (0,0)] + coordinates::Vector{NTuple{2,Float64}} = [(0, 0), (0, 0), (0, 0), (0, 0)] gx_LatLonQuad(id, targetId, coordinates) = (@assert length(coordinates) == 4; new(id, targetId, coordinates)) end #-----------------------------------------------------------------------------# gx_Playlist <: Object @@ -299,7 +304,8 @@ Base.@kwdef mutable struct Snippet <: KMLElement{(:maxLines,)} content::String = "" maxLines::Int = 2 end -showxml(io::IO, o::Snippet) = printstyled(io, "", color=:light_yellow) +showxml(io::IO, o::Snippet) = + printstyled(io, "", color = :light_yellow) #-----------------------------------------------------------------------------# ExtendedData # TODO: Support ExtendedData. This currently prints incorrectly. Base.@kwdef mutable struct ExtendedData <: NoAttributes @@ -344,11 +350,13 @@ Base.@kwdef mutable struct Placemark <: Feature @feature @option Geometry::Geometry end -GeoInterface.isfeature(o::Type{Placemark}) = true -GeoInterface.trait(o::Placemark) = GeoInterface.FeatureTrait() -GeoInterface.properties(o::Placemark) = NamedTuple(OrderedDict(f => getfield(o,f) for f in setdiff(fieldnames(Placemark), [:Geometry]))) -GeoInterface.geometry(o::Placemark) = o.Geometry - +GeoInterface.isfeature(::Placemark) = true +GeoInterface.isfeature(::Type{Placemark}) = true +const _PLACEMARK_PROP_FIELDS = Tuple(filter(!=(Symbol("Geometry")), fieldnames(Placemark))) +GeoInterface.properties(p::Placemark) = (; (f => getfield(p, f) for f in _PLACEMARK_PROP_FIELDS)...) +GeoInterface.trait(::Placemark) = GeoInterface.FeatureTrait() +GeoInterface.geometry(p::Placemark) = p.Geometry +GeoInterface.crs(::Placemark) = GeoInterface.default_crs() #-===========================================================================-# Geometries #-----------------------------------------------------------------------------# Point <: Geometry @@ -356,11 +364,11 @@ Base.@kwdef mutable struct Point <: Geometry @object @option extrude::Bool @altitude_mode_elements - @option coordinates::Union{NTuple{2, Float64}, NTuple{3, Float64}} + @option coordinates::Union{NTuple{2,Float64},NTuple{3,Float64}} end -GeoInterface.geomtrait(o::Point) = GeoInterface.PointTrait() -GeoInterface.ncoord(::GeoInterface.PointTrait, o::Point) = length(o.coordinates) -GeoInterface.getcoord(::GeoInterface.PointTrait, o::Point, i) = o.coordinates[i] +GeoInterface.geomtrait(::Point) = GeoInterface.PointTrait() +GeoInterface.ncoord(::GeoInterface.PointTrait, pt::Point) = length(pt.coordinates) +GeoInterface.getcoord(::GeoInterface.PointTrait, pt::Point, i) = pt.coordinates[i] #-----------------------------------------------------------------------------# LineString <: Geometry Base.@kwdef mutable struct LineString <: Geometry @@ -370,11 +378,13 @@ Base.@kwdef mutable struct LineString <: Geometry @option tessellate::Bool @altitude_mode_elements @option gx_drawOrder::Int - @option coordinates::Union{Vector{NTuple{2, Float64}}, Vector{NTuple{3, Float64}}} + @option coordinates::Union{Vector{NTuple{2,Float64}},Vector{NTuple{3,Float64}}} end GeoInterface.geomtrait(::LineString) = GeoInterface.LineStringTrait() -GeoInterface.ngeom(::GeoInterface.LineStringTrait, o::LineString) = length(o.coordinates) -GeoInterface.getgeom(::GeoInterface.LineStringTrait, o::LineString, i) = Point(coordinates=o.coordinates[i]) +GeoInterface.ncoord(::GeoInterface.LineStringTrait, ls::LineString) = length(ls.coordinates) +GeoInterface.getcoord(::GeoInterface.LineStringTrait, ls::LineString, i::Int) = ls.coordinates[i] +GeoInterface.ngeom(::GeoInterface.LineStringTrait, ls::LineString) = GeoInterface.ncoord(GeoInterface.LineStringTrait(), ls) +GeoInterface.getgeom(::GeoInterface.LineStringTrait, ls::LineString, i) = GeoInterface.coordtuple(ls.coordinates[i]...) #-----------------------------------------------------------------------------# LinearRing <: Geometry Base.@kwdef mutable struct LinearRing <: Geometry @@ -383,11 +393,13 @@ Base.@kwdef mutable struct LinearRing <: Geometry @option extrude::Bool @option tessellate::Bool @altitude_mode_elements - @option coordinates::Union{Vector{NTuple{2, Float64}}, Vector{NTuple{3, Float64}}} + @option coordinates::Union{Vector{NTuple{2,Float64}},Vector{NTuple{3,Float64}}} end GeoInterface.geomtrait(::LinearRing) = GeoInterface.LinearRingTrait() -GeoInterface.ngeom(::GeoInterface.LinearRingTrait, o::LinearRing) = length(o.coordinates) -GeoInterface.getgeom(::GeoInterface.LinearRingTrait, o::LinearRing, i) = Point(coordinates=o.coordinates[i]) +GeoInterface.ncoord(::GeoInterface.LinearRingTrait, lr::LinearRing) = length(lr.coordinates) +GeoInterface.getcoord(::GeoInterface.LinearRingTrait, lr::LinearRing, i::Int) = lr.coordinates[i] +GeoInterface.ngeom(::GeoInterface.LinearRingTrait, lr::LinearRing) = GeoInterface.ncoord(GeoInterface.LinearRingTrait(), lr) +GeoInterface.getgeom(::GeoInterface.LinearRingTrait, lr, i) = GeoInterface.coordtuple(lr.coordinates[i]...) #-----------------------------------------------------------------------------# Polygon <: Geometry Base.@kwdef mutable struct Polygon <: Geometry @@ -398,20 +410,26 @@ Base.@kwdef mutable struct Polygon <: Geometry outerBoundaryIs::LinearRing = LinearRing() @option innerBoundaryIs::Vector{LinearRing} end -GeoInterface.geomtrait(o::Polygon) = GeoInterface.PolygonTrait() -GeoInterface.ngeom(::GeoInterface.PolygonTrait, o::Polygon) = 1 + length(o.innerBoundaryIs) -GeoInterface.getgeom(::GeoInterface.PolygonTrait, o::Polygon, i) = i == 1 ? o.outerBoundaryIs : o.innerBoundaryIs[i-1] -GeoInterface.ncoord(::GeoInterface.PolygonTrait, o::Polygon) = length(first(o.outerBoundaryIs.coordinates)) +GeoInterface.geomtrait(::Polygon) = GeoInterface.PolygonTrait() +GeoInterface.ngeom(::GeoInterface.PolygonTrait, poly::Polygon) = 1 + length(poly.innerBoundaryIs) +GeoInterface.getgeom(::GeoInterface.PolygonTrait, poly::Polygon, i) = + i == 1 ? poly.outerBoundaryIs : poly.innerBoundaryIs[i-1] +GeoInterface.ncoord(::GeoInterface.PolygonTrait, poly::Polygon) = length(poly.outerBoundaryIs.coordinates) +GeoInterface.ncoord(::GeoInterface.PolygonTrait, poly::Polygon, ring::Int) = + ring == 1 ? length(poly.outerBoundaryIs.coordinates) : length(poly.innerBoundaryIs[ring-1].coordinates) +GeoInterface.getcoord(::GeoInterface.PolygonTrait, poly::Polygon, ring::Int, i::Int) = + ring == 1 ? poly.outerBoundaryIs.coordinates[i] : poly.innerBoundaryIs[ring-1].coordinates[i] #-----------------------------------------------------------------------------# MultiGeometry <: Geometry Base.@kwdef mutable struct MultiGeometry <: Geometry @object @option Geometries::Vector{Geometry} end -GeoInterface.geomtrait(geom::MultiGeometry) = GeoInterface.GeometryCollectionTrait() -GeoInterface.ncoord(::GeoInterface.GeometryCollectionTrait, geom::MultiGeometry) = GeoInterface.ncoord(first(o.Geometries)) -GeoInterface.ngeom(::GeoInterface.GeometryCollectionTrait, geom::MultiGeometry) = length(o.Geometries) -GeoInterface.getgeom(::GeoInterface.GeometryCollectionTrait, geom::MultiGeometry, i) = o.Geometries[i] +GeoInterface.geomtrait(::MultiGeometry) = GeoInterface.GeometryCollectionTrait() +GeoInterface.ngeom(::GeoInterface.GeometryCollectionTrait, mg::MultiGeometry) = length(mg.Geometries) +GeoInterface.getgeom(::GeoInterface.GeometryCollectionTrait, mg::MultiGeometry, i::Int) = mg.Geometries[i] +GeoInterface.ncoord(::GeoInterface.GeometryCollectionTrait, mg::MultiGeometry) = + isempty(mg.Geometries) ? 0 : GeoInterface.ncoord(GeoInterface.geomtrait(mg.Geometries[1]), mg.Geometries[1]) #-----------------------------------------------------------------------------# Model <: Geometry Base.@kwdef mutable struct Alias <: NoAttributes @@ -723,7 +741,9 @@ Base.@kwdef mutable struct LookAt <: AbstractView end #-----------------------------------------------------------------------------# build TAG_TO_TYPE -"Recursively collect every concrete descendant of `root`." +""" +Recursively collect every concrete descendant of `root` and store it in the `TAG_TO_TYPE` dictionary. +""" function _collect_concrete!(root) # accept *any* type value for S in subtypes(root) if isabstracttype(S) diff --git a/src/parsing.jl b/src/parsing.jl index fcf0ca8..c09b8c6 100644 --- a/src/parsing.jl +++ b/src/parsing.jl @@ -7,10 +7,10 @@ coordinate_string(x::Vector) = join(coordinate_string.(x), '\n') # KMLElement → Node Node(o::T) where {T<:Enums.AbstractKMLEnum} = XML.Element(typetag(T), o.value) -function Node(o::T) where {names, T <: KMLElement{names}} +function Node(o::T) where {names,T<:KMLElement{names}} tag = typetag(T) attributes = Dict(string(k) => string(getfield(o, k)) for k in names if !isnothing(getfield(o, k))) - element_fields = filter(x -> !isnothing(getfield(o,x)), setdiff(fieldnames(T), names)) + element_fields = filter(x -> !isnothing(getfield(o, x)), setdiff(fieldnames(T), names)) isempty(element_fields) && return XML.Node(XML.Element, tag, attributes) children = Node[] for field in element_fields @@ -56,13 +56,13 @@ function object(node::XML.Node) return String(XML.value(only(node))) # plain text end # 4. fallback to the old generic code ------------------------ - return _object_slow(node) + return _object_slow(node) end # original implementation, renamed _object_slow(node::XML.Node) = begin sym = tagsym(node) - if sym in names(Enums, all=true) + if sym in names(Enums, all = true) return getproperty(Enums, sym)(XML.value(only(node))) end if sym in names(KML) || sym == :Pair @@ -77,17 +77,28 @@ _object_slow(node::XML.Node) = begin nothing end +function _parse_coordinates(txt::AbstractString) + nums = parse.(Float64, filter(!isempty, split(txt, r"[,\s]+"))) + if mod(length(nums), 3) == 0 # triples → (lon,lat,alt) + return [Tuple(@view nums[i:i+2]) for i = 1:3:length(nums)] + elseif mod(length(nums), 2) == 0 # pairs → (lon,lat) + return [Tuple(@view nums[i:i+1]) for i = 1:2:length(nums)] + else + error("Coordinate list length $(length(nums)) is not a multiple of 2 or 3") + end +end + function add_element!(parent::Union{Object,KMLElement}, child::XML.Node) # ── 0. pre‑compute a few things ─────────────────────────────── - fname = Symbol(replace(child.tag, ":" => "_")) # tag → field name + fname = Symbol(replace(child.tag, ":" => "_")) # tag → field name simple = XML.is_simple(child) # ── 1. *Scalar* leaf node (fast path) ───────────────────────── if simple - hasfield(typeof(parent), fname) || return # ignore strangers + hasfield(typeof(parent), fname) || return # ignore strangers - txt = String(XML.value(XML.only(child))) # raw text - ftype = typemap(typeof(parent))[fname] # cached Dict + txt = String(XML.value(XML.only(child))) # raw text + ftype = typemap(typeof(parent))[fname] # cached Dict # (a) the easy built‑ins val = if ftype === String @@ -102,14 +113,12 @@ function add_element!(parent::Union{Object,KMLElement}, child::XML.Node) ftype(txt) # (b) the special coordinate string elseif fname === :coordinates - # Many public KML files omit the comma after an altitude `0`, leaving only a - # space before the next longitude. Google Earth and GDAL accept this. See - # https://kml4earth.appspot.com/kmlErrata.html#validation - vec = [Tuple(parse.(Float64, split(v, r"[,\s]+"))) for v in split(txt)] - (ftype <: Union{Nothing,Tuple}) ? first(vec) : vec + vec = _parse_coordinates(txt) + val = (ftype <: Union{Nothing,Tuple}) ? first(vec) : vec # (c) fallback – let the generic helper take a stab else - autosetfield!(parent, fname, txt); return + autosetfield!(parent, fname, txt) + return end setfield!(parent, fname, val) @@ -154,15 +163,21 @@ tagsym(x::Node) = tagsym(XML.tag(x)) function add_attributes!(o::Union{Object,KMLElement}, source::Node) attr = XML.attributes(source) - !isnothing(attr) && for (k,v) in attr - autosetfield!(o, tagsym(k), v) + isnothing(attr) && return + + tm = typemap(o) # cached Dict + for (k, v) in attr + startswith(k, "xmlns") && continue # skip namespace decls + sym = tagsym(k) + haskey(tm, sym) || continue # skip unknown attrs + autosetfield!(o, sym, v) end end function autosetfield!(o::Union{Object,KMLElement}, sym::Symbol, txt::String) ftype = typemap(o)[sym] - val = if ftype <: AbstractString + val = if ftype <: AbstractString txt elseif ftype <: Integer txt == "" ? zero(ftype) : parse(ftype, txt) @@ -172,12 +187,9 @@ function autosetfield!(o::Union{Object,KMLElement}, sym::Symbol, txt::String) txt == "1" || lowercase(txt) == "true" elseif ftype <: Enums.AbstractKMLEnum ftype(txt) - elseif sym === :coordinates - # Many public KML files omit the comma after an altitude `0`, leaving only a - # space before the next longitude. Google Earth and GDAL accept this. See - # https://kml4earth.appspot.com/kmlErrata.html#validation - vec = [Tuple(parse.(Float64, split(v, r"[,\s]+"))) for v in split(txt)] - (ftype <: Union{Nothing,Tuple}) ? first(vec) : vec + elseif fname === :coordinates + vec = _parse_coordinates(txt) + val = (ftype <: Union{Nothing,Tuple}) ? first(vec) : vec else txt # last‑ditch: store the raw string end From 7bf79980e850a166f759bfb3f25eb6130e5501be Mon Sep 17 00:00:00 2001 From: mathieu17g Date: Fri, 9 May 2025 12:49:05 +0200 Subject: [PATCH 07/59] Add a Tables.jl interface Add a show method for Geometry checking on output color capabiliy to be shown correctly in a DataFrame Fix some issues on GeoInterface Use StaticArrays to lower the allocations while parsing coordinates --- Project.toml | 6 ++ src/KML.jl | 68 ++++++++++--- src/TablesInterface.jl | 224 +++++++++++++++++++++++++++++++++++++++++ src/parsing.jl | 11 +- 4 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 src/TablesInterface.jl diff --git a/Project.toml b/Project.toml index cbeae4c..ca8141a 100644 --- a/Project.toml +++ b/Project.toml @@ -7,11 +7,17 @@ version = "0.2.5" GeoInterface = "cf35fbd7-0cd7-5166-be24-54bfbe79505f" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" +StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" +Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" XML = "72c71f33-b9b6-44de-8c94-c961784809e2" [compat] GeoInterface = "1.3" OrderedCollections = "1" +REPL = "1.11.0" +StaticArrays = "1.9.13" +Tables = "1.12.0" XML = "0.3.0" julia = "1" diff --git a/src/KML.jl b/src/KML.jl index a665ec3..f7d255e 100644 --- a/src/KML.jl +++ b/src/KML.jl @@ -4,6 +4,11 @@ using OrderedCollections: OrderedDict using GeoInterface: GeoInterface import XML: XML, Node using InteractiveUtils: subtypes +using StaticArrays + +#-----------------------------------------------------------------------------# Constants +const Coord2 = SVector{2,Float64} +const Coord3 = SVector{3,Float64} #-----------------------------------------------------------------------------# Memoization constants # object types are stored as symbols in the TAG_TO_TYPE dictionary @@ -289,7 +294,7 @@ end #-----------------------------------------------------------------------------# gx_LatLonQuad <: Object Base.@kwdef mutable struct gx_LatLonQuad <: Object @object - coordinates::Vector{NTuple{2,Float64}} = [(0, 0), (0, 0), (0, 0), (0, 0)] + coordinates::Vector{Coord2} = [(0, 0), (0, 0), (0, 0), (0, 0)] gx_LatLonQuad(id, targetId, coordinates) = (@assert length(coordinates) == 4; new(id, targetId, coordinates)) end #-----------------------------------------------------------------------------# gx_Playlist <: Object @@ -364,7 +369,7 @@ Base.@kwdef mutable struct Point <: Geometry @object @option extrude::Bool @altitude_mode_elements - @option coordinates::Union{NTuple{2,Float64},NTuple{3,Float64}} + @option coordinates::Union{Coord2,Coord3} end GeoInterface.geomtrait(::Point) = GeoInterface.PointTrait() GeoInterface.ncoord(::GeoInterface.PointTrait, pt::Point) = length(pt.coordinates) @@ -378,13 +383,13 @@ Base.@kwdef mutable struct LineString <: Geometry @option tessellate::Bool @altitude_mode_elements @option gx_drawOrder::Int - @option coordinates::Union{Vector{NTuple{2,Float64}},Vector{NTuple{3,Float64}}} + @option coordinates::Union{Vector{Coord2},Vector{Coord3}} end GeoInterface.geomtrait(::LineString) = GeoInterface.LineStringTrait() GeoInterface.ncoord(::GeoInterface.LineStringTrait, ls::LineString) = length(ls.coordinates) GeoInterface.getcoord(::GeoInterface.LineStringTrait, ls::LineString, i::Int) = ls.coordinates[i] GeoInterface.ngeom(::GeoInterface.LineStringTrait, ls::LineString) = GeoInterface.ncoord(GeoInterface.LineStringTrait(), ls) -GeoInterface.getgeom(::GeoInterface.LineStringTrait, ls::LineString, i) = GeoInterface.coordtuple(ls.coordinates[i]...) +GeoInterface.getgeom(::GeoInterface.LineStringTrait, ls::LineString, i) = ls.coordinates[i] #-----------------------------------------------------------------------------# LinearRing <: Geometry Base.@kwdef mutable struct LinearRing <: Geometry @@ -393,13 +398,15 @@ Base.@kwdef mutable struct LinearRing <: Geometry @option extrude::Bool @option tessellate::Bool @altitude_mode_elements - @option coordinates::Union{Vector{NTuple{2,Float64}},Vector{NTuple{3,Float64}}} + @option coordinates::Union{Vector{Coord2},Vector{Coord3}} end GeoInterface.geomtrait(::LinearRing) = GeoInterface.LinearRingTrait() -GeoInterface.ncoord(::GeoInterface.LinearRingTrait, lr::LinearRing) = length(lr.coordinates) -GeoInterface.getcoord(::GeoInterface.LinearRingTrait, lr::LinearRing, i::Int) = lr.coordinates[i] +GeoInterface.ncoord(::GeoInterface.LinearRingTrait, lr::LinearRing) = + (lr.coordinates === nothing) ? 0 : length(lr.coordinates) +GeoInterface.getcoord(::GeoInterface.LinearRingTrait, lr::LinearRing, i::Int) = + lr.coordinates === nothing ? error("LinearRing has no coordinates") : lr.coordinates[i] GeoInterface.ngeom(::GeoInterface.LinearRingTrait, lr::LinearRing) = GeoInterface.ncoord(GeoInterface.LinearRingTrait(), lr) -GeoInterface.getgeom(::GeoInterface.LinearRingTrait, lr, i) = GeoInterface.coordtuple(lr.coordinates[i]...) +GeoInterface.getgeom(::GeoInterface.LinearRingTrait, lr::LinearRing, i) = lr.coordinates[i] #-----------------------------------------------------------------------------# Polygon <: Geometry Base.@kwdef mutable struct Polygon <: Geometry @@ -411,12 +418,26 @@ Base.@kwdef mutable struct Polygon <: Geometry @option innerBoundaryIs::Vector{LinearRing} end GeoInterface.geomtrait(::Polygon) = GeoInterface.PolygonTrait() -GeoInterface.ngeom(::GeoInterface.PolygonTrait, poly::Polygon) = 1 + length(poly.innerBoundaryIs) +GeoInterface.ngeom(::GeoInterface.PolygonTrait, poly::Polygon) = + 1 + (poly.innerBoundaryIs === nothing ? 0 : length(poly.innerBoundaryIs)) GeoInterface.getgeom(::GeoInterface.PolygonTrait, poly::Polygon, i) = i == 1 ? poly.outerBoundaryIs : poly.innerBoundaryIs[i-1] -GeoInterface.ncoord(::GeoInterface.PolygonTrait, poly::Polygon) = length(poly.outerBoundaryIs.coordinates) +GeoInterface.ncoord(::GeoInterface.PolygonTrait, poly::Polygon) = + (poly.outerBoundaryIs === nothing || poly.outerBoundaryIs.coordinates === nothing) ? 0 : + length(poly.outerBoundaryIs.coordinates) + GeoInterface.ncoord(::GeoInterface.PolygonTrait, poly::Polygon, ring::Int) = - ring == 1 ? length(poly.outerBoundaryIs.coordinates) : length(poly.innerBoundaryIs[ring-1].coordinates) + if ring == 1 + (poly.outerBoundaryIs === nothing || poly.outerBoundaryIs.coordinates === nothing) ? 0 : + length(poly.outerBoundaryIs.coordinates) + else + idx = ring - 1 + ( + poly.innerBoundaryIs === nothing || + idx > length(poly.innerBoundaryIs) || + poly.innerBoundaryIs[idx].coordinates === nothing + ) ? 0 : length(poly.innerBoundaryIs[idx].coordinates) + end GeoInterface.getcoord(::GeoInterface.PolygonTrait, poly::Polygon, ring::Int, i::Int) = ring == 1 ? poly.outerBoundaryIs.coordinates[i] : poly.innerBoundaryIs[ring-1].coordinates[i] @@ -426,7 +447,8 @@ Base.@kwdef mutable struct MultiGeometry <: Geometry @option Geometries::Vector{Geometry} end GeoInterface.geomtrait(::MultiGeometry) = GeoInterface.GeometryCollectionTrait() -GeoInterface.ngeom(::GeoInterface.GeometryCollectionTrait, mg::MultiGeometry) = length(mg.Geometries) +GeoInterface.ngeom(::GeoInterface.GeometryCollectionTrait, mg::MultiGeometry) = + (mg.Geometries === nothing ? 0 : length(mg.Geometries)) GeoInterface.getgeom(::GeoInterface.GeometryCollectionTrait, mg::MultiGeometry, i::Int) = mg.Geometries[i] GeoInterface.ncoord(::GeoInterface.GeometryCollectionTrait, mg::MultiGeometry) = isempty(mg.Geometries) ? 0 : GeoInterface.ncoord(GeoInterface.geomtrait(mg.Geometries[1]), mg.Geometries[1]) @@ -757,9 +779,31 @@ function _collect_concrete!(root) # accept *any* type value end _collect_concrete!(KMLElement) +# ─────────────────── pretty‑print geometries ───────────────────── +import Base: show + +function show(io::IO, g::Geometry) + # Only colour when *both* keys are true *and* we’re writing to a TTY + color_ok = (io isa Base.TTY) && get(io, :color, false) + + trait = GeoInterface.geomtrait(g) + nvert = GeoInterface.ncoord(trait, g) + nparts = GeoInterface.ngeom(trait, g) + + if color_ok + printstyled(io, nameof(typeof(g)); color = :cyan) + else + print(io, nameof(typeof(g))) + end + print(io, "(vertices=", nvert, ", parts=", nparts, ')') +end + #-----------------------------------------------------------------------------# parsing include("parsing.jl") +#-----------------------------------------------------------------------------# TablesInterface.jl +include("TablesInterface.jl") + #-----------------------------------------------------------------------------# exports export KMLFile, Enums, object diff --git a/src/TablesInterface.jl b/src/TablesInterface.jl new file mode 100644 index 0000000..b2d44da --- /dev/null +++ b/src/TablesInterface.jl @@ -0,0 +1,224 @@ +module TablesInterface + +import Tables +import REPL.TerminalMenus: RadioMenu, request +using ..KML: KMLFile, Feature, Document, Folder, Placemark +using ..KML: Geometry, Point, LineString, LinearRing, Polygon, MultiGeometry + +# Table type representing a collection of Placemark rows +struct PlacemarkTable + placemarks::Vector{Placemark} +end + +# Constructor: build a PlacemarkTable from a KML file, optionally filtering by layer name +function PlacemarkTable(file::KMLFile; layer::Union{Nothing,String} = nothing) + features = _top_level_features(file) + containers, direct_pls, parent = _determine_layers(features) + selected = _select_layer(containers, direct_pls, parent, layer) + + # -- build the candidate list ------------------------------------------ + local placemarks::Vector{Placemark} + if selected === nothing + placemarks = Placemark[] # no layers selected + elseif selected isa Vector{Placemark} + placemarks = selected # already plain placemarks + else + placemarks = collect_placemarks(selected) # recurse into chosen container + end + + # -- drop entries that have no geometry -------------------------------- + placemarks = filter(pl -> pl.Geometry !== nothing, placemarks) + + return PlacemarkTable(placemarks) +end + +# Helper: get all top‑level Feature elements from a KML file +function _top_level_features(file::KMLFile)::Vector{Feature} + # 1. direct children that *are* Feature objects + feats = Feature[c for c in file.children if c isa Feature] + + # 2. if none found, recurse one level into the first container + if isempty(feats) + for c in file.children + if (c isa Document || c isa Folder) && c.Features !== nothing + append!(feats, c.Features) + end + end + end + return feats +end + +# Helper: Determine layer groupings from top-level features. +# Returns a tuple (containers, direct_pls, parent_container): +# - containers: Vector of Document/Folder that can act as sub-layers +# - direct_pls: Vector of Placemark at this level not inside a container +# - parent_container: if there's exactly one top-level Document/Folder, this is it (for naming context) +function _determine_layers(features::Vector{Feature}) + if length(features) == 1 + f = features[1] + if f isa Document || f isa Folder + # Single top-level container; check its children for sub-containers + subfeatures = (hasproperty(f, :Features) && f.Features !== nothing) ? f.Features : Feature[] + containers = [x for x in subfeatures if x isa Document || x isa Folder] + direct_pls = Placemark[x for x in subfeatures if x isa Placemark] + return containers, direct_pls, f + else + # Single top-level Placemark or other feature (no containers) + return Feature[], (f isa Placemark ? [f] : Placemark[]), nothing + end + else + # Multiple top-level features; some may be containers, some placemarks + containers = [x for x in features if x isa Document || x isa Folder] + direct_pls = Placemark[x for x in features if x isa Placemark] + return containers, direct_pls, nothing + end +end + +# Helper: Select a specific layer (Document/Folder or group of placemarks) given potential containers and direct placemarks. +# If `layer` is provided (by name), selects that; otherwise uses TerminalMenus for disambiguation if multiple options exist. +function _select_layer( + containers::Vector{Feature}, + direct_pls::Vector{Placemark}, + parent::Union{Document,Folder,Nothing}, + layer::Union{Nothing,String}, +) + # If a layer name is explicitly given, try to find a matching container by name + if layer !== nothing + for c in containers + if c.name !== nothing && c.name == layer + return c + end + end + # If there are no sub-containers and the single parent has the matching name, select the parent (for its direct placemarks) + if isempty(containers) && parent !== nothing && parent.name !== nothing && parent.name == layer + return parent + end + error("Layer \"$layer\" not found in KML file") + end + + # No layer specified: if multiple possible layers, prompt user to choose + options = String[] + candidates = Any[] + for c in containers + # Use container's name if available, otherwise a placeholder + name = c.name !== nothing ? c.name : (c isa Document ? "" : "") + push!(options, name) + push!(candidates, c) + end + if !isempty(direct_pls) + # Add an option for placemarks not inside any container (un-grouped placemarks) + if parent !== nothing && parent.name !== nothing + push!(options, parent.name * " (unfoldered placemarks)") + else + push!(options, "") + end + push!(candidates, direct_pls) + end + + # ────────────────── choose the layer ──────────────────────────── + # If there is zero or exactly one candidate, we can return immediately. + if length(options) <= 1 + return isempty(candidates) ? nothing : candidates[1] + end + + # More than one layer → decide whether we can ask the user interactively. + # + # We treat the session as “interactive” if *both* stdin and stdout are + # real terminals (TTYs). That excludes VS Code notebooks, Jupyter, + # batch scripts, etc., where TerminalMenus would just hang. + _is_interactive() = (stdin isa Base.TTY) && (stdout isa Base.TTY) && isinteractive() + + if _is_interactive() + menu = RadioMenu(options; pagesize = min(length(options), 10)) + idx = request("Select a layer to use:", menu) + idx == -1 && error("Layer selection cancelled by user.") + return candidates[idx] + else + # Non‑interactive context (e.g. DataFrame(...) in a script or notebook): + # pick the first container automatically and warn the user so they know + # how to override. + @warn "Multiple layers detected in KML; selecting layer \"$(options[1])\" automatically. " * + "Pass keyword `layer=\"$(options[1])\"` (or another layer name) to choose a different one." + return candidates[1] + end +end + +# Helper: Recursively collect all Placemark objects under a given Feature (Document/Folder). +function collect_placemarks(feat::Feature)::Vector{Placemark} + if feat isa Placemark + return [feat] + elseif feat isa Document || feat isa Folder + # Traverse into containers + local result = Placemark[] + local subfeatures = (hasproperty(feat, :Features) && feat.Features !== nothing) ? feat.Features : Feature[] + for sub in subfeatures + append!(result, collect_placemarks(sub)) + end + return result + else + # Other feature types (GroundOverlay, NetworkLink, etc.) contain no placemarks + return Placemark[] + end +end + +# TODO: could be deleted if we don't need to flatten geometries +# Flatten a Geometry into a vector of coordinate tuples (each tuple is (lon, lat) or (lon, lat, alt)) +function flatten_geometry(geom::Geometry)::Vector{Tuple} + if geom isa Point + # Point: single coordinate tuple + return [geom.coordinates] + elseif geom isa LineString || geom isa LinearRing + # LineString/LinearRing: sequence of coordinate tuples + return collect(geom.coordinates) + elseif geom isa Polygon + # Polygon: flatten outer ring + all inner rings + coords = Tuple[] # collect tuples here + + # --- outer ring ---------------------------------------------------- + if geom.outerBoundaryIs !== nothing + append!(coords, flatten_geometry(geom.outerBoundaryIs)) + end + + # --- inner rings --------------------------------------------------- + if geom.innerBoundaryIs !== nothing + for ring in geom.innerBoundaryIs + append!(coords, flatten_geometry(ring)) + end + end + return coords + elseif geom isa MultiGeometry + # MultiGeometry: concatenate coordinates from all sub-geometries + local coords = Tuple[] + # Retrieve the vector of sub-geometries (field name might be `Geometries`) + local subgeoms = + hasproperty(geom, :Geometries) ? geom.Geometries : (hasproperty(geom, :geometries) ? geom.geometries : nothing) + if subgeoms !== nothing + for g in subgeoms + append!(coords, flatten_geometry(g)) + end + end + return coords + else + # Other geometry types (Model, etc.): return empty vector (no coordinates to flatten) + return Tuple[] + end +end + +# --- Tables.jl interface ---------------------------------------------------- +Tables.istable(::Type{PlacemarkTable}) = true +Tables.columnaccess(::Type{PlacemarkTable}) = true + +Tables.schema(::PlacemarkTable) = Tables.Schema( + (:name, :description, :geometry), + (String, String, Geometry) # raw geometry objects +) + +function Tables.columns(t::PlacemarkTable) + return ( + name = [ pl.name === nothing ? "" : pl.name for pl in t.placemarks ], + description = [ pl.description === nothing ? "" : pl.description for pl in t.placemarks ], + geometry = [ pl.Geometry for pl in t.placemarks ], + ) +end + +end # module TablesInterface diff --git a/src/parsing.jl b/src/parsing.jl index c09b8c6..3ce7fb6 100644 --- a/src/parsing.jl +++ b/src/parsing.jl @@ -79,10 +79,13 @@ end function _parse_coordinates(txt::AbstractString) nums = parse.(Float64, filter(!isempty, split(txt, r"[,\s]+"))) - if mod(length(nums), 3) == 0 # triples → (lon,lat,alt) - return [Tuple(@view nums[i:i+2]) for i = 1:3:length(nums)] - elseif mod(length(nums), 2) == 0 # pairs → (lon,lat) - return [Tuple(@view nums[i:i+1]) for i = 1:2:length(nums)] + + if mod(length(nums), 3) == 0 # lon‑lat‑alt triples + return [ SVector{3}(nums[i], nums[i+1], nums[i+2]) + for i = 1:3:length(nums) ] + elseif mod(length(nums), 2) == 0 # lon‑lat pairs + return [ SVector{2}(nums[i], nums[i+1]) + for i = 1:2:length(nums) ] else error("Coordinate list length $(length(nums)) is not a multiple of 2 or 3") end From 4c63f1ee651e78caa83f157af6818acbe9bce81c Mon Sep 17 00:00:00 2001 From: mathieu17g Date: Sun, 11 May 2025 06:08:58 +0200 Subject: [PATCH 08/59] Refactored the packages in KML.jl, types.jl, parsing.jl, geointerface.jl and tables.jl Enhanced _object_slow function (WIP) --- Project.toml | 2 + src/KML.jl | 841 ++--------------------------------------- src/TablesInterface.jl | 224 ----------- src/geointerface.jl | 70 ++++ src/parsing.jl | 116 +++++- src/tables.jl | 146 +++++++ src/types.jl | 663 ++++++++++++++++++++++++++++++++ 7 files changed, 1008 insertions(+), 1054 deletions(-) delete mode 100644 src/TablesInterface.jl create mode 100644 src/geointerface.jl create mode 100644 src/tables.jl create mode 100644 src/types.jl diff --git a/Project.toml b/Project.toml index ca8141a..8c62d1f 100644 --- a/Project.toml +++ b/Project.toml @@ -7,6 +7,7 @@ version = "0.2.5" GeoInterface = "cf35fbd7-0cd7-5166-be24-54bfbe79505f" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +Parsers = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" @@ -15,6 +16,7 @@ XML = "72c71f33-b9b6-44de-8c94-c961784809e2" [compat] GeoInterface = "1.3" OrderedCollections = "1" +Parsers = "2.8.3" REPL = "1.11.0" StaticArrays = "1.9.13" Tables = "1.12.0" diff --git a/src/KML.jl b/src/KML.jl index f7d255e..9bc7f63 100644 --- a/src/KML.jl +++ b/src/KML.jl @@ -1,817 +1,30 @@ module KML +# ─── base deps ──────────────────────────────────────────────────────────────── using OrderedCollections: OrderedDict -using GeoInterface: GeoInterface -import XML: XML, Node -using InteractiveUtils: subtypes -using StaticArrays - -#-----------------------------------------------------------------------------# Constants -const Coord2 = SVector{2,Float64} -const Coord3 = SVector{3,Float64} - -#-----------------------------------------------------------------------------# Memoization constants -# object types are stored as symbols in the TAG_TO_TYPE dictionary -const TAG_TO_TYPE = Dict{Symbol,DataType}() -# typemap cache: key = the struct type (e.g. Placemark), value = Dict of field ⇒ Type -const _FIELD_MAP_CACHE = IdDict{DataType,Dict{Symbol,Type}}() - -#----------------------------------------------------------------------------# utils -const INDENT = " " - -macro def(name, definition) - return quote - macro $(esc(name))() - esc($(Expr(:quote, definition))) - end - end -end - -@def altitude_mode_elements begin - altitudeMode::Union{Nothing,Enums.altitudeMode} = nothing - gx_altitudeMode::Union{Nothing,Enums.gx_altitudeMode} = nothing -end - -# @option field::Type → field::Union{Nothing, Type} = nothing -macro option(expr) - expr.head == :(::) || error("@default only works on type annotations e.g. `field::Type`") - expr.args[2] = Expr(:curly, :Union, :Nothing, expr.args[2]) - return esc(Expr(:(=), expr, :nothing)) -end - -# Same as `@option` but prints a warning. -macro required(expr) - expr.head == :(::) || error("@required only works on type annotations e.g. `field::Type`") - expr.args[2] = Expr(:curly, :Union, :Nothing, expr.args[2]) - warning = "Field :$(expr.args[1]) is required by KML spec but has been initialized as `nothing`." - return esc(Expr(:(=), expr, :(@warn($warning)))) -end - -name(T::Type) = replace(string(T), r"([a-zA-Z]*\.)" => "") -name(o) = name(typeof(o)) - -function all_concrete_subtypes(T) - types = subtypes(T) - out = filter(isconcretetype, types) - for S in filter(isabstracttype, types) - append!(out, all_concrete_subtypes(S)) - end - return out -end - -function all_abstract_subtypes(T) - types = filter(isabstracttype, subtypes(T)) - for t in types - append!(types, all_abstract_subtypes(t)) - end - types -end - -#-----------------------------------------------------------------------------# KMLElement -# `attr_names` fields print as attributes, everything else as an element -abstract type KMLElement{attr_names} <: XML.AbstractXMLNode end - -const NoAttributes = KMLElement{()} - -function Base.show(io::IO, o::T) where {names,T<:KMLElement{names}} - printstyled(io, T; color = :light_cyan) - print(io, ": [") - show(io, Node(o)) - print(io, ']') -end - -# XML Interface -XML.tag(o::KMLElement) = name(o) - -function XML.attributes(o::T) where {names,T<:KMLElement{names}} - OrderedDict(k => getfield(o, k) for k in names if !isnothing(getfield(o, k))) -end - -XML.children(o::KMLElement) = XML.children(Node(o)) - -typemap(o) = typemap(typeof(o)) -# fast typemap using the cache -function typemap(::Type{T}) where {T<:KMLElement} - get!(_FIELD_MAP_CACHE, T) do - Dict{Symbol,Type}(name => Base.nonnothingtype(S) for (name, S) in zip(fieldnames(T), fieldtypes(T))) - end -end - -Base.:(==)(a::T, b::T) where {T<:KMLElement} = all(getfield(a, f) == getfield(b, f) for f in fieldnames(T)) - - -#-----------------------------------------------------------------------------# "Enums" -module Enums -import ..NoAttributes, ..name -using XML - -abstract type AbstractKMLEnum <: NoAttributes end - -Base.show(io::IO, o::AbstractKMLEnum) = print(io, typeof(o), ": ", repr(o.value)) -Base.convert(::Type{T}, x::String) where {T<:AbstractKMLEnum} = T(x) -Base.string(o::AbstractKMLEnum) = o.value - -macro kml_enum(T, vals...) - esc( - quote - struct $T <: AbstractKMLEnum - value::String - function $T(value) - string(value) ∈ $(string.(vals)) || - error($(string(T)) * " ∉ " * join($vals, ", ") * ". Found: " * string(value)) - new(string(value)) - end - end - end, - ) -end -@kml_enum altitudeMode clampToGround relativeToGround absolute -@kml_enum gx_altitudeMode relativeToSeaFloor clampToSeaFloor -@kml_enum refreshMode onChange onInterval onExpire -@kml_enum viewRefreshMode never onStop onRequest onRegion -@kml_enum shape rectangle cylinder sphere -@kml_enum gridOrigin lowerLeft upperLeft -@kml_enum displayMode default hide -@kml_enum listItemType check checkOffOnly checkHideChildren radioFolder -@kml_enum units fraction pixels insetPixels -@kml_enum itemIconState open closed error fetching0 fetching1 fetching2 -@kml_enum styleState normal highlight -@kml_enum colorMode normal random -@kml_enum flyToMode smooth bounce -end - -#-----------------------------------------------------------------------------# KMLFile -mutable struct KMLFile - children::Vector{Union{Node,KMLElement}} # Union with XML.Node to allow Comment and CData -end -KMLFile(content::KMLElement...) = KMLFile(collect(content)) - -Base.push!(o::KMLFile, el::Union{Node,KMLElement}) = push!(o.children, el) - -# TODO: print better summary of file -function Base.show(io::IO, o::KMLFile) - print(io, "KMLFile ") - printstyled(io, '(', Base.format_bytes(Base.summarysize(o)), ')'; color = :light_black) -end - -function Node(o::KMLFile) - children = [ - Node(XML.Declaration, nothing, OrderedDict("version" => "1.0", "encoding" => "UTF-8")), - Node(XML.Element, "kml", OrderedDict("xmlns" => "http://earth.google.com/kml/2.2"), nothing, Node.(o.children)), - ] - Node(XML.Document, nothing, nothing, nothing, children) -end - - -Base.:(==)(a::KMLFile, b::KMLFile) = all(getfield(a, f) == getfield(b, f) for f in fieldnames(KMLFile)) - -# read -Base.read(io::IO, ::Type{KMLFile}) = KMLFile(read(io, XML.Node)) -Base.read(filename::AbstractString, ::Type{KMLFile}) = KMLFile(read(filename, XML.Node)) -function KMLFile(doc::XML.Node) - i = findfirst(x -> x.tag == "kml", XML.children(doc)) - isnothing(i) && error("No tag found in file.") - KMLFile(map(object, XML.children(doc[i]))) -end - -Base.parse(::Type{KMLFile}, s::AbstractString) = KMLFile(XML.parse(s, Node)) - -Writable = Union{KMLFile,KMLElement,Node} - -write(io::IO, o::Writable; kw...) = XML.write(io, Node(o); kw...) -write(file::AbstractString, o::Writable; kw...) = XML.write(file, Node(o); kw...) -write(o::Writable; kw...) = XML.write(Node(o); kw...) - -#-----------------------------------------------------------------------------# Object -abstract type Object <: KMLElement{(:id, :targetId)} end - -abstract type Feature <: Object end -abstract type Overlay <: Feature end -abstract type Container <: Feature end - -abstract type Geometry <: Object end -GeoInterface.isgeometry(::Geometry) = true -GeoInterface.isgeometry(::Type{<:Geometry}) = true -GeoInterface.crs(::Geometry) = GeoInterface.default_crs() - -abstract type StyleSelector <: Object end - -abstract type TimePrimitive <: Object end - -abstract type AbstractView <: Object end - -abstract type SubStyle <: Object end -abstract type ColorStyle <: SubStyle end - -abstract type gx_TourPrimitive <: Object end - - -#-===========================================================================-# Immediate Subtypes of Object -@def object begin - @option id::String - @option targetId::String -end - -#-----------------------------------------------------------------------------# Link <: Object -Base.@kwdef mutable struct Link <: Object - @object - @option href::String - @option refreshMode::Enums.refreshMode - @option refreshInterval::Float64 - @option viewRefreshMode::Enums.viewRefreshMode - @option viewRefreshTime::Float64 - @option viewBoundScale::Float64 - @option viewFormat::String - @option httpQuery::String -end -#-----------------------------------------------------------------------------# Icon <: Object -Base.@kwdef mutable struct Icon <: Object - @object - @option href::String - @option refreshMode::Enums.refreshMode - @option refreshInterval::Float64 - @option viewRefreshMode::Enums.viewRefreshMode - @option viewRefreshTime::Float64 - @option viewBoundScale::Float64 - @option viewFormat::String - @option httpQuery::String -end -#-----------------------------------------------------------------------------# Orientation <: Object -Base.@kwdef mutable struct Orientation <: Object - @object - @option heading::Float64 - @option tilt::Float64 - @option roll::Float64 -end -#-----------------------------------------------------------------------------# Location <: Object -Base.@kwdef mutable struct Location <: Object - @object - @option longitude::Float64 - @option latitude::Float64 - @option altitude::Float64 -end -#-----------------------------------------------------------------------------# Scale <: Object -Base.@kwdef mutable struct Scale <: Object - @object - @option x::Float64 - @option y::Float64 - @option z::Float64 -end -#-----------------------------------------------------------------------------# Lod <: Object -Base.@kwdef mutable struct Lod <: Object - @object - minLodPixels::Int = 128 - @option maxLodPixels::Int - @option minFadeExtent::Int - @option maxFadeExtent::Int -end -#-----------------------------------------------------------------------------# LatLonBox <: Object -Base.@kwdef mutable struct LatLonBox <: Object - @object - north::Float64 = 0 - south::Float64 = 0 - east::Float64 = 0 - west::Float64 = 0 - @option rotation::Float64 -end -#-----------------------------------------------------------------------------# LatLonAltBox <: Object -Base.@kwdef mutable struct LatLonAltBox <: Object - @object - north::Float64 = 0 - south::Float64 = 0 - east::Float64 = 0 - west::Float64 = 0 - @option minAltitude::Float64 - @option maxAltitude::Float64 - @altitude_mode_elements -end -#-----------------------------------------------------------------------------# Region <: Object -Base.@kwdef mutable struct Region <: Object - @object - LatLonAltBox::LatLonAltBox = LatLonAltBox(north = 0, south = 0, east = 0, west = 0) - @option Lod::Lod -end -#-----------------------------------------------------------------------------# gx_LatLonQuad <: Object -Base.@kwdef mutable struct gx_LatLonQuad <: Object - @object - coordinates::Vector{Coord2} = [(0, 0), (0, 0), (0, 0), (0, 0)] - gx_LatLonQuad(id, targetId, coordinates) = (@assert length(coordinates) == 4; new(id, targetId, coordinates)) -end -#-----------------------------------------------------------------------------# gx_Playlist <: Object -Base.@kwdef mutable struct gx_Playlist - @object - gx_TourPrimitives::Vector{gx_TourPrimitive} = [] -end - -#-===========================================================================-# Things that don't quite conform -#-----------------------------------------------------------------------------# Snippet -Base.@kwdef mutable struct Snippet <: KMLElement{(:maxLines,)} - content::String = "" - maxLines::Int = 2 -end -showxml(io::IO, o::Snippet) = - printstyled(io, "", color = :light_yellow) -#-----------------------------------------------------------------------------# ExtendedData -# TODO: Support ExtendedData. This currently prints incorrectly. -Base.@kwdef mutable struct ExtendedData <: NoAttributes - @required children::Vector{Any} -end - - -#-===========================================================================-# Features -@def feature begin - @object - @option name::String - @option visibility::Bool - @option open::Bool - @option atom_author::String - @option atom_link::String - @option address::String - @option xal_AddressDetails::String - @option phoneNumber::String - @option Snippet::Snippet - @option description::String - @option AbstractView::AbstractView - @option TimePrimitive::TimePrimitive - @option styleUrl::String - @option StyleSelectors::Vector{StyleSelector} - @option Region::Region - @option ExtendedData::ExtendedData -end -#-----------------------------------------------------------------------------# gx_Tour <: Feature -Base.@kwdef mutable struct gx_Tour <: Feature - @feature - @option gx_Playlist::gx_Playlist -end -#-----------------------------------------------------------------------------# NetworkLink <: Feature -Base.@kwdef mutable struct NetworkLink <: Feature - @feature - @option refreshVisibility::Bool - @option flyToView::Bool - Link::Link = Link() -end -#-----------------------------------------------------------------------------# Placemark <: Feature -Base.@kwdef mutable struct Placemark <: Feature - @feature - @option Geometry::Geometry -end -GeoInterface.isfeature(::Placemark) = true -GeoInterface.isfeature(::Type{Placemark}) = true -const _PLACEMARK_PROP_FIELDS = Tuple(filter(!=(Symbol("Geometry")), fieldnames(Placemark))) -GeoInterface.properties(p::Placemark) = (; (f => getfield(p, f) for f in _PLACEMARK_PROP_FIELDS)...) -GeoInterface.trait(::Placemark) = GeoInterface.FeatureTrait() -GeoInterface.geometry(p::Placemark) = p.Geometry -GeoInterface.crs(::Placemark) = GeoInterface.default_crs() - -#-===========================================================================-# Geometries -#-----------------------------------------------------------------------------# Point <: Geometry -Base.@kwdef mutable struct Point <: Geometry - @object - @option extrude::Bool - @altitude_mode_elements - @option coordinates::Union{Coord2,Coord3} -end -GeoInterface.geomtrait(::Point) = GeoInterface.PointTrait() -GeoInterface.ncoord(::GeoInterface.PointTrait, pt::Point) = length(pt.coordinates) -GeoInterface.getcoord(::GeoInterface.PointTrait, pt::Point, i) = pt.coordinates[i] - -#-----------------------------------------------------------------------------# LineString <: Geometry -Base.@kwdef mutable struct LineString <: Geometry - @object - @option gx_altitudeOffset::Float64 - @option extrude::Bool - @option tessellate::Bool - @altitude_mode_elements - @option gx_drawOrder::Int - @option coordinates::Union{Vector{Coord2},Vector{Coord3}} -end -GeoInterface.geomtrait(::LineString) = GeoInterface.LineStringTrait() -GeoInterface.ncoord(::GeoInterface.LineStringTrait, ls::LineString) = length(ls.coordinates) -GeoInterface.getcoord(::GeoInterface.LineStringTrait, ls::LineString, i::Int) = ls.coordinates[i] -GeoInterface.ngeom(::GeoInterface.LineStringTrait, ls::LineString) = GeoInterface.ncoord(GeoInterface.LineStringTrait(), ls) -GeoInterface.getgeom(::GeoInterface.LineStringTrait, ls::LineString, i) = ls.coordinates[i] - -#-----------------------------------------------------------------------------# LinearRing <: Geometry -Base.@kwdef mutable struct LinearRing <: Geometry - @object - @option gx_altitudeOffset::Float64 - @option extrude::Bool - @option tessellate::Bool - @altitude_mode_elements - @option coordinates::Union{Vector{Coord2},Vector{Coord3}} -end -GeoInterface.geomtrait(::LinearRing) = GeoInterface.LinearRingTrait() -GeoInterface.ncoord(::GeoInterface.LinearRingTrait, lr::LinearRing) = - (lr.coordinates === nothing) ? 0 : length(lr.coordinates) -GeoInterface.getcoord(::GeoInterface.LinearRingTrait, lr::LinearRing, i::Int) = - lr.coordinates === nothing ? error("LinearRing has no coordinates") : lr.coordinates[i] -GeoInterface.ngeom(::GeoInterface.LinearRingTrait, lr::LinearRing) = GeoInterface.ncoord(GeoInterface.LinearRingTrait(), lr) -GeoInterface.getgeom(::GeoInterface.LinearRingTrait, lr::LinearRing, i) = lr.coordinates[i] - -#-----------------------------------------------------------------------------# Polygon <: Geometry -Base.@kwdef mutable struct Polygon <: Geometry - @object - @option extrude::Bool - @option tessellate::Bool - @altitude_mode_elements - outerBoundaryIs::LinearRing = LinearRing() - @option innerBoundaryIs::Vector{LinearRing} -end -GeoInterface.geomtrait(::Polygon) = GeoInterface.PolygonTrait() -GeoInterface.ngeom(::GeoInterface.PolygonTrait, poly::Polygon) = - 1 + (poly.innerBoundaryIs === nothing ? 0 : length(poly.innerBoundaryIs)) -GeoInterface.getgeom(::GeoInterface.PolygonTrait, poly::Polygon, i) = - i == 1 ? poly.outerBoundaryIs : poly.innerBoundaryIs[i-1] -GeoInterface.ncoord(::GeoInterface.PolygonTrait, poly::Polygon) = - (poly.outerBoundaryIs === nothing || poly.outerBoundaryIs.coordinates === nothing) ? 0 : - length(poly.outerBoundaryIs.coordinates) - -GeoInterface.ncoord(::GeoInterface.PolygonTrait, poly::Polygon, ring::Int) = - if ring == 1 - (poly.outerBoundaryIs === nothing || poly.outerBoundaryIs.coordinates === nothing) ? 0 : - length(poly.outerBoundaryIs.coordinates) - else - idx = ring - 1 - ( - poly.innerBoundaryIs === nothing || - idx > length(poly.innerBoundaryIs) || - poly.innerBoundaryIs[idx].coordinates === nothing - ) ? 0 : length(poly.innerBoundaryIs[idx].coordinates) - end -GeoInterface.getcoord(::GeoInterface.PolygonTrait, poly::Polygon, ring::Int, i::Int) = - ring == 1 ? poly.outerBoundaryIs.coordinates[i] : poly.innerBoundaryIs[ring-1].coordinates[i] - -#-----------------------------------------------------------------------------# MultiGeometry <: Geometry -Base.@kwdef mutable struct MultiGeometry <: Geometry - @object - @option Geometries::Vector{Geometry} -end -GeoInterface.geomtrait(::MultiGeometry) = GeoInterface.GeometryCollectionTrait() -GeoInterface.ngeom(::GeoInterface.GeometryCollectionTrait, mg::MultiGeometry) = - (mg.Geometries === nothing ? 0 : length(mg.Geometries)) -GeoInterface.getgeom(::GeoInterface.GeometryCollectionTrait, mg::MultiGeometry, i::Int) = mg.Geometries[i] -GeoInterface.ncoord(::GeoInterface.GeometryCollectionTrait, mg::MultiGeometry) = - isempty(mg.Geometries) ? 0 : GeoInterface.ncoord(GeoInterface.geomtrait(mg.Geometries[1]), mg.Geometries[1]) - -#-----------------------------------------------------------------------------# Model <: Geometry -Base.@kwdef mutable struct Alias <: NoAttributes - @option targetHref::String - @option sourceHref::String -end -Base.@kwdef mutable struct ResourceMap <: NoAttributes - @option Aliases::Vector{Alias} -end -Base.@kwdef mutable struct Model <: Geometry - @object - @altitude_mode_elements - @option Location::Location - @option Orientation::Orientation - @option Scale::Scale - @option Link::Link - @option ResourceMap::ResourceMap -end -GeoInterface.isgeometry(::Type{Model}) = false -#-----------------------------------------------------------------------------# gx_Track <: Geometry -Base.@kwdef mutable struct gx_Track <: Geometry - @object - @altitude_mode_elements - @option when::String - @option gx_coord::String - @option gx_angles::String - @option Model::Model - @option ExtendedData::ExtendedData -end -GeoInterface.isgeometry(::Type{gx_Track}) = false -#-----------------------------------------------------------------------------# gx_MultiTrack <: Geometry -Base.@kwdef mutable struct gx_MultiTrack - @object - @altitude_mode_elements - @option gx_interpolate::Bool - @option gx_Track::Vector{gx_Track} -end - - -#-===========================================================================-# Overlays -@def overlay begin - @feature - @option color::String - @option drawOrder::Int - @option Icon::Icon -end - -#-----------------------------------------------------------------------------# PhotoOverlay <: Overlay -Base.@kwdef mutable struct ViewVolume <: NoAttributes - @option leftFov::Float64 - @option rightFov::Float64 - @option bottomFov::Float64 - @option topFov::Float64 - @option near::Float64 -end -Base.@kwdef mutable struct ImagePyramid <: NoAttributes - @option tileSize::Int - @option maxWidth::Int - @option maxHeight::Int - @option gridOrigin::Enums.gridOrigin -end -Base.@kwdef mutable struct PhotoOverlay <: Overlay - @overlay - @option rotation::Float64 - @option ViewVolume::ViewVolume - @option ImagePyramid::ImagePyramid - @option Point::Point - @option shape::Enums.shape -end -#-----------------------------------------------------------------------------# ScreenOverlay <: Overlay -Base.@kwdef mutable struct overlayXY <: KMLElement{(:x, :y, :xunits, :yunits)} - x::Float64 = 0.5 - y::Float64 = 0.5 - xunits::Enums.units = "fraction" - yunits::Enums.units = "fraction" -end -Base.@kwdef mutable struct screenXY <: KMLElement{(:x, :y, :xunits, :yunits)} - x::Float64 = 0.5 - y::Float64 = 0.5 - xunits::Enums.units = "fraction" - yunits::Enums.units = "fraction" -end -Base.@kwdef mutable struct rotationXY <: KMLElement{(:x, :y, :xunits, :yunits)} - x::Float64 = 0.5 - y::Float64 = 0.5 - xunits::Enums.units = "fraction" - yunits::Enums.units = "fraction" -end -Base.@kwdef mutable struct size <: KMLElement{(:x, :y, :xunits, :yunits)} - x::Float64 = 0.5 - y::Float64 = 0.5 - xunits::Enums.units = "fraction" - yunits::Enums.units = "fraction" -end -Base.@kwdef mutable struct ScreenOverlay <: Overlay - @overlay - overlayXY::overlayXY = overlayXY() - screenXY::screenXY = screenXY() - rotationXY::rotationXY = rotationXY() - size::size = size() - rotation::Float64 = 0.0 -end -#-----------------------------------------------------------------------------# GroundOverlay <: Overlay -Base.@kwdef mutable struct GroundOverlay <: Overlay - @overlay - @option altitude::Float64 - @altitude_mode_elements - @option LatLonBox::LatLonBox - @option GXLatLonQuad::gx_LatLonQuad -end - - - -#-===========================================================================-# SubStyles -#-----------------------------------------------------------------------------# BalloonStyle <: SubStyle -Base.@kwdef mutable struct BalloonStyle <: SubStyle - @object - @option bgColor::String - @option textColor::String - @option text::String - @option displayMode::Enums.displayMode -end -#-----------------------------------------------------------------------------# ListStyle <: SubStyle -Base.@kwdef mutable struct ItemIcon <: KMLElement{()} - @option state::Enums.styleState - @option href::String -end -Base.@kwdef mutable struct ListStyle <: SubStyle - @object - @option listItemType::Symbol - @option bgColor::String - @option ItemIcons::Vector{ItemIcon} -end - -#-===========================================================================-# ColorStyles -@def colorstyle begin - @object - @option color::String - @option colorMode::Enums.colorMode -end -#-----------------------------------------------------------------------------# LineStyle <: ColorStyle -Base.@kwdef mutable struct LineStyle <: ColorStyle - @colorstyle - @option width::Float64 - @option gx_outerColor::String - @option gx_outerWidth::Float64 - @option gx_physicalWidth::Float64 - @option gx_labelVisibility::Bool -end -#-----------------------------------------------------------------------------# PolyStyle <: ColorStyle -Base.@kwdef mutable struct PolyStyle <: ColorStyle - @colorstyle - @option fill::Bool - @option outline::Bool -end -#-----------------------------------------------------------------------------# IconStyle -Base.@kwdef mutable struct hotSpot <: KMLElement{(:x, :y, :xunits, :yunits)} - @option x::Float64 - @option y::Float64 - @option xunits::Enums.units - @option yunits::Enums.units -end -Base.@kwdef mutable struct IconStyle <: ColorStyle - @colorstyle - @option scale::Float64 - @option heading::Float64 - @option Icon::Icon - @option hotSpot::hotSpot -end -#-----------------------------------------------------------------------------# LabelStyle -Base.@kwdef mutable struct LabelStyle <: ColorStyle - @colorstyle - @option scale::Float64 -end - - -#-===========================================================================-# StyleSelectors -# These need to come before Containers since `Document` holds `Style`s -#-----------------------------------------------------------------------------# Style <: StyleSelector -Base.@kwdef mutable struct Style <: StyleSelector - @object - @option IconStyle::IconStyle - @option LabelStyle::LabelStyle - @option LineStyle::LineStyle - @option PolyStyle::PolyStyle - @option BalloonStyle::BalloonStyle - @option ListStyle::ListStyle -end -#-----------------------------------------------------------------------------# StyleMap <: StyleSelector -Base.@kwdef mutable struct Pair <: Object - @object - @option key::Enums.styleState - @option styleUrl::String - @option Style::Style -end - -Base.@kwdef mutable struct StyleMap <: StyleSelector - @object - @option Pairs::Vector{Pair} -end - - -#-===========================================================================-# Containers -#-----------------------------------------------------------------------------# Folder <: Container -Base.@kwdef mutable struct Folder <: Container - @feature - @option Features::Vector{Feature} -end -#-----------------------------------------------------------------------------# Document <: Container -Base.@kwdef mutable struct SimpleField <: KMLElement{(:type, :name)} - type::String - name::String - @option displayName::String -end -Base.@kwdef mutable struct Schema <: KMLElement{(:id,)} - id::String - @option SimpleFields::Vector{SimpleField} -end -Base.@kwdef mutable struct Document <: Container - @feature - @option Schemas::Vector{Schema} - @option Features::Vector{Feature} -end - - - - -#-===========================================================================-# gx_TourPrimitives -#-----------------------------------------------------------------------------# gx_AnimatedUpdate -Base.@kwdef mutable struct Change <: KMLElement{()} - child::KMLElement -end -Base.@kwdef mutable struct Create <: KMLElement{()} - child::KMLElement -end -Base.@kwdef mutable struct Delete <: KMLElement{()} - child::KMLElement -end -Base.@kwdef mutable struct Update <: KMLElement{()} - targetHref::String - @option Change::Change - @option Create::Create - @option Delete::Delete -end -Base.@kwdef mutable struct gx_AnimatedUpdate <: gx_TourPrimitive - @object - @option gx_duration::Float64 - @option Update::Update - @option gx_delayedStart::Float64 -end -#-----------------------------------------------------------------------------# gx_FlyTo -Base.@kwdef mutable struct gx_FlyTo <: gx_TourPrimitive - @object - @option gx_duration::Float64 - @option gx_flyToMode::Enums.flyToMode - @option AbstractView::AbstractView -end -#-----------------------------------------------------------------------------# gx_SoundCue -Base.@kwdef mutable struct gx_SoundCue <: gx_TourPrimitive - @object - @option href::String - @option gx_delayedStart::Float64 -end -#-----------------------------------------------------------------------------# gx_TourControl -Base.@kwdef mutable struct gx_TourControl - @object - gx_playMode::String = "pause" -end -#-----------------------------------------------------------------------------# gx_Wait -Base.@kwdef mutable struct gx_Wait - @object - @option gx_duration::Float64 -end -#-===========================================================================-# AbstractView -Base.@kwdef mutable struct gx_option - name::String - enabled::Bool -end - -Base.@kwdef mutable struct gx_ViewerOptions - options::Vector{gx_option} -end - -#-----------------------------------------------------------------------------# Camera -Base.@kwdef mutable struct Camera <: AbstractView - @object - @option TimePrimitive::TimePrimitive - @option gx_ViewerOptions::gx_ViewerOptions - @option longitude::Float64 - @option latitude::Float64 - @option altitude::Float64 - @option heading::Float64 - @option tilt::Float64 - @option roll::Float64 - @altitude_mode_elements -end - -Base.@kwdef mutable struct LookAt <: AbstractView - @object - @option TimePrimitive::TimePrimitive - @option gx_ViewerOptions::gx_ViewerOptions - @option longitude::Float64 - @option latitude::Float64 - @option altitude::Float64 - @option heading::Float64 - @option tilt::Float64 - @option range::Float64 - @altitude_mode_elements -end - -#-----------------------------------------------------------------------------# build TAG_TO_TYPE -""" -Recursively collect every concrete descendant of `root` and store it in the `TAG_TO_TYPE` dictionary. -""" -function _collect_concrete!(root) # accept *any* type value - for S in subtypes(root) - if isabstracttype(S) - _collect_concrete!(S) # recurse first - else - sym = Symbol(replace(string(S), "KML." => "")) # :Document, … - TAG_TO_TYPE[sym] = S - end - end - return -end -_collect_concrete!(KMLElement) - -# ─────────────────── pretty‑print geometries ───────────────────── -import Base: show - -function show(io::IO, g::Geometry) - # Only colour when *both* keys are true *and* we’re writing to a TTY - color_ok = (io isa Base.TTY) && get(io, :color, false) - - trait = GeoInterface.geomtrait(g) - nvert = GeoInterface.ncoord(trait, g) - nparts = GeoInterface.ngeom(trait, g) - - if color_ok - printstyled(io, nameof(typeof(g)); color = :cyan) - else - print(io, nameof(typeof(g))) - end - print(io, "(vertices=", nvert, ", parts=", nparts, ')') -end - -#-----------------------------------------------------------------------------# parsing -include("parsing.jl") - -#-----------------------------------------------------------------------------# TablesInterface.jl -include("TablesInterface.jl") - -#-----------------------------------------------------------------------------# exports -export KMLFile, Enums, object - -for T in vcat(all_concrete_subtypes(KMLElement), all_abstract_subtypes(Object)) - if T != KML.Pair - e = Symbol(replace(string(T), "KML." => "")) - @eval export $e - end -end - -end #module +import XML: XML, read as xmlread, parse as xmlparse, write as xmlwrite, Node # xml parsing / writing +using InteractiveUtils: subtypes # all subtypes of a type +using StaticArrays # small fixed‑size coordinate vectors +using Parsers + +# ─── split implementation files ────────────────────────────────────────────── +include("types.jl") # all KML data types & helpers (no GeoInterface) +include("geointerface.jl") # GeoInterface extensions & pretty printing +include("parsing.jl") # XML → struct & struct → XML +# include("TablesInterface.jl") # Tables.jl wrapper for Placemarks +include("tables.jl") # Tables.jl wrapper for Placemarks +using .TablesBridge # re‑export ? + +# ─── re‑export public names ────────────────────────────────────────────────── +export KMLFile, Enums, object # the “root” objects most users need +export PlacemarkTable + +for T in vcat( + all_concrete_subtypes(KMLElement), # concrete types + all_abstract_subtypes(Object), # abstract sub‑hierarchy +) + T === KML.Pair && continue # skip internal helper + @eval export $(Symbol(replace(string(T), "KML." => ""))) +end + +end # module diff --git a/src/TablesInterface.jl b/src/TablesInterface.jl deleted file mode 100644 index b2d44da..0000000 --- a/src/TablesInterface.jl +++ /dev/null @@ -1,224 +0,0 @@ -module TablesInterface - -import Tables -import REPL.TerminalMenus: RadioMenu, request -using ..KML: KMLFile, Feature, Document, Folder, Placemark -using ..KML: Geometry, Point, LineString, LinearRing, Polygon, MultiGeometry - -# Table type representing a collection of Placemark rows -struct PlacemarkTable - placemarks::Vector{Placemark} -end - -# Constructor: build a PlacemarkTable from a KML file, optionally filtering by layer name -function PlacemarkTable(file::KMLFile; layer::Union{Nothing,String} = nothing) - features = _top_level_features(file) - containers, direct_pls, parent = _determine_layers(features) - selected = _select_layer(containers, direct_pls, parent, layer) - - # -- build the candidate list ------------------------------------------ - local placemarks::Vector{Placemark} - if selected === nothing - placemarks = Placemark[] # no layers selected - elseif selected isa Vector{Placemark} - placemarks = selected # already plain placemarks - else - placemarks = collect_placemarks(selected) # recurse into chosen container - end - - # -- drop entries that have no geometry -------------------------------- - placemarks = filter(pl -> pl.Geometry !== nothing, placemarks) - - return PlacemarkTable(placemarks) -end - -# Helper: get all top‑level Feature elements from a KML file -function _top_level_features(file::KMLFile)::Vector{Feature} - # 1. direct children that *are* Feature objects - feats = Feature[c for c in file.children if c isa Feature] - - # 2. if none found, recurse one level into the first container - if isempty(feats) - for c in file.children - if (c isa Document || c isa Folder) && c.Features !== nothing - append!(feats, c.Features) - end - end - end - return feats -end - -# Helper: Determine layer groupings from top-level features. -# Returns a tuple (containers, direct_pls, parent_container): -# - containers: Vector of Document/Folder that can act as sub-layers -# - direct_pls: Vector of Placemark at this level not inside a container -# - parent_container: if there's exactly one top-level Document/Folder, this is it (for naming context) -function _determine_layers(features::Vector{Feature}) - if length(features) == 1 - f = features[1] - if f isa Document || f isa Folder - # Single top-level container; check its children for sub-containers - subfeatures = (hasproperty(f, :Features) && f.Features !== nothing) ? f.Features : Feature[] - containers = [x for x in subfeatures if x isa Document || x isa Folder] - direct_pls = Placemark[x for x in subfeatures if x isa Placemark] - return containers, direct_pls, f - else - # Single top-level Placemark or other feature (no containers) - return Feature[], (f isa Placemark ? [f] : Placemark[]), nothing - end - else - # Multiple top-level features; some may be containers, some placemarks - containers = [x for x in features if x isa Document || x isa Folder] - direct_pls = Placemark[x for x in features if x isa Placemark] - return containers, direct_pls, nothing - end -end - -# Helper: Select a specific layer (Document/Folder or group of placemarks) given potential containers and direct placemarks. -# If `layer` is provided (by name), selects that; otherwise uses TerminalMenus for disambiguation if multiple options exist. -function _select_layer( - containers::Vector{Feature}, - direct_pls::Vector{Placemark}, - parent::Union{Document,Folder,Nothing}, - layer::Union{Nothing,String}, -) - # If a layer name is explicitly given, try to find a matching container by name - if layer !== nothing - for c in containers - if c.name !== nothing && c.name == layer - return c - end - end - # If there are no sub-containers and the single parent has the matching name, select the parent (for its direct placemarks) - if isempty(containers) && parent !== nothing && parent.name !== nothing && parent.name == layer - return parent - end - error("Layer \"$layer\" not found in KML file") - end - - # No layer specified: if multiple possible layers, prompt user to choose - options = String[] - candidates = Any[] - for c in containers - # Use container's name if available, otherwise a placeholder - name = c.name !== nothing ? c.name : (c isa Document ? "" : "") - push!(options, name) - push!(candidates, c) - end - if !isempty(direct_pls) - # Add an option for placemarks not inside any container (un-grouped placemarks) - if parent !== nothing && parent.name !== nothing - push!(options, parent.name * " (unfoldered placemarks)") - else - push!(options, "") - end - push!(candidates, direct_pls) - end - - # ────────────────── choose the layer ──────────────────────────── - # If there is zero or exactly one candidate, we can return immediately. - if length(options) <= 1 - return isempty(candidates) ? nothing : candidates[1] - end - - # More than one layer → decide whether we can ask the user interactively. - # - # We treat the session as “interactive” if *both* stdin and stdout are - # real terminals (TTYs). That excludes VS Code notebooks, Jupyter, - # batch scripts, etc., where TerminalMenus would just hang. - _is_interactive() = (stdin isa Base.TTY) && (stdout isa Base.TTY) && isinteractive() - - if _is_interactive() - menu = RadioMenu(options; pagesize = min(length(options), 10)) - idx = request("Select a layer to use:", menu) - idx == -1 && error("Layer selection cancelled by user.") - return candidates[idx] - else - # Non‑interactive context (e.g. DataFrame(...) in a script or notebook): - # pick the first container automatically and warn the user so they know - # how to override. - @warn "Multiple layers detected in KML; selecting layer \"$(options[1])\" automatically. " * - "Pass keyword `layer=\"$(options[1])\"` (or another layer name) to choose a different one." - return candidates[1] - end -end - -# Helper: Recursively collect all Placemark objects under a given Feature (Document/Folder). -function collect_placemarks(feat::Feature)::Vector{Placemark} - if feat isa Placemark - return [feat] - elseif feat isa Document || feat isa Folder - # Traverse into containers - local result = Placemark[] - local subfeatures = (hasproperty(feat, :Features) && feat.Features !== nothing) ? feat.Features : Feature[] - for sub in subfeatures - append!(result, collect_placemarks(sub)) - end - return result - else - # Other feature types (GroundOverlay, NetworkLink, etc.) contain no placemarks - return Placemark[] - end -end - -# TODO: could be deleted if we don't need to flatten geometries -# Flatten a Geometry into a vector of coordinate tuples (each tuple is (lon, lat) or (lon, lat, alt)) -function flatten_geometry(geom::Geometry)::Vector{Tuple} - if geom isa Point - # Point: single coordinate tuple - return [geom.coordinates] - elseif geom isa LineString || geom isa LinearRing - # LineString/LinearRing: sequence of coordinate tuples - return collect(geom.coordinates) - elseif geom isa Polygon - # Polygon: flatten outer ring + all inner rings - coords = Tuple[] # collect tuples here - - # --- outer ring ---------------------------------------------------- - if geom.outerBoundaryIs !== nothing - append!(coords, flatten_geometry(geom.outerBoundaryIs)) - end - - # --- inner rings --------------------------------------------------- - if geom.innerBoundaryIs !== nothing - for ring in geom.innerBoundaryIs - append!(coords, flatten_geometry(ring)) - end - end - return coords - elseif geom isa MultiGeometry - # MultiGeometry: concatenate coordinates from all sub-geometries - local coords = Tuple[] - # Retrieve the vector of sub-geometries (field name might be `Geometries`) - local subgeoms = - hasproperty(geom, :Geometries) ? geom.Geometries : (hasproperty(geom, :geometries) ? geom.geometries : nothing) - if subgeoms !== nothing - for g in subgeoms - append!(coords, flatten_geometry(g)) - end - end - return coords - else - # Other geometry types (Model, etc.): return empty vector (no coordinates to flatten) - return Tuple[] - end -end - -# --- Tables.jl interface ---------------------------------------------------- -Tables.istable(::Type{PlacemarkTable}) = true -Tables.columnaccess(::Type{PlacemarkTable}) = true - -Tables.schema(::PlacemarkTable) = Tables.Schema( - (:name, :description, :geometry), - (String, String, Geometry) # raw geometry objects -) - -function Tables.columns(t::PlacemarkTable) - return ( - name = [ pl.name === nothing ? "" : pl.name for pl in t.placemarks ], - description = [ pl.description === nothing ? "" : pl.description for pl in t.placemarks ], - geometry = [ pl.Geometry for pl in t.placemarks ], - ) -end - -end # module TablesInterface diff --git a/src/geointerface.jl b/src/geointerface.jl new file mode 100644 index 0000000..434b530 --- /dev/null +++ b/src/geointerface.jl @@ -0,0 +1,70 @@ +#------------------------------------------------------------------------------ +# geointerface.jl – adds GeoInterface + pretty printing for KML geometries +#------------------------------------------------------------------------------ + +using GeoInterface +import Base: show + +# bring the KML types from parent module into local scope --------------------- +const GI = GeoInterface + +# --- Generic helpers --------------------------------------------------------- +GI.isgeometry(::KML.Geometry) = true +GI.isgeometry(::Type{<:KML.Geometry}) = true +GI.crs(::KML.Geometry) = GI.default_crs() + +# --- Point ------------------------------------------------------------------- +GI.geomtrait(::KML.Point) = GI.PointTrait() +GI.ncoord(::GI.PointTrait, p::KML.Point) = length(p.coordinates) +GI.getcoord(::GI.PointTrait, p::KML.Point, i) = p.coordinates[i] + +# --- LineString -------------------------------------------------------------- +GI.geomtrait(::KML.LineString) = GI.LineStringTrait() +GI.ncoord(::GI.LineStringTrait, ls::KML.LineString) = length(ls.coordinates) +GI.getcoord(::GI.LineStringTrait, ls::KML.LineString, i) = ls.coordinates[i] +GI.ngeom(::GI.LineStringTrait, ls::KML.LineString) = GI.ncoord(GI.LineStringTrait(), ls) +GI.getgeom(::GI.LineStringTrait, ls::KML.LineString, i) = ls.coordinates[i] + +# --- LinearRing -------------------------------------------------------------- +GI.geomtrait(::KML.LinearRing) = GI.LinearRingTrait() +GI.ncoord(::GI.LinearRingTrait, lr::KML.LinearRing) = lr.coordinates === nothing ? 0 : length(lr.coordinates) +GI.getcoord(::GI.LinearRingTrait, lr::KML.LinearRing, i) = lr.coordinates[i] +GI.ngeom(::GI.LinearRingTrait, lr::KML.LinearRing) = GI.ncoord(GI.LinearRingTrait(), lr) +GI.getgeom(::GI.LinearRingTrait, lr::KML.LinearRing, i) = lr.coordinates[i] + +# --- Polygon ----------------------------------------------------------------- +GI.geomtrait(::KML.Polygon) = GI.PolygonTrait() +GI.ngeom(::GI.PolygonTrait, poly::KML.Polygon) = 1 + (poly.innerBoundaryIs === nothing ? 0 : length(poly.innerBoundaryIs)) +GI.getgeom(::GI.PolygonTrait, poly::KML.Polygon, i) = (i == 1 ? poly.outerBoundaryIs : poly.innerBoundaryIs[i-1]) +GI.ncoord(::GI.PolygonTrait, poly::KML.Polygon) = + (poly.outerBoundaryIs === nothing ? 0 : length(poly.outerBoundaryIs.coordinates)) +GI.ncoord(::GI.PolygonTrait, poly::KML.Polygon, ring::Int) = + ring == 1 ? length(poly.outerBoundaryIs.coordinates) : length(poly.innerBoundaryIs[ring-1].coordinates) +GI.getcoord(::GI.PolygonTrait, poly::KML.Polygon, ring::Int, i::Int) = + ring == 1 ? poly.outerBoundaryIs.coordinates[i] : poly.innerBoundaryIs[ring-1].coordinates[i] + +# --- MultiGeometry ----------------------------------------------------------- +GI.geomtrait(::KML.MultiGeometry) = GI.GeometryCollectionTrait() +GI.ngeom(::GI.GeometryCollectionTrait, mg::KML.MultiGeometry) = (mg.Geometries === nothing ? 0 : length(mg.Geometries)) +GI.getgeom(::GI.GeometryCollectionTrait, mg::KML.MultiGeometry, i) = mg.Geometries[i] +GI.ncoord(::GI.GeometryCollectionTrait, mg::KML.MultiGeometry) = + (isempty(mg.Geometries) ? 0 : GI.ncoord(GI.geomtrait(mg.Geometries[1]), mg.Geometries[1])) + +# --- Placemark feature helpers ---------------------------------------------- +GI.isfeature(::KML.Placemark) = true +GI.isfeature(::Type{KML.Placemark}) = true +const _PLACEMARK_PROP_FIELDS = Tuple(filter(!=(Symbol("Geometry")), fieldnames(KML.Placemark))) +GI.properties(p::KML.Placemark) = (; (f => getfield(p, f) for f in _PLACEMARK_PROP_FIELDS)...) +GI.trait(::KML.Placemark) = GI.FeatureTrait() +GI.geometry(p::KML.Placemark) = p.Geometry +GI.crs(::KML.Placemark) = GI.default_crs() + +# --- pretty print helpers ---------------------------------------------------- +function show(io::IO, g::KML.Geometry) + color_ok = (io isa Base.TTY) && get(io, :color, false) + trait = GI.geomtrait(g) + verts = GI.ncoord(trait, g) + parts = GI.ngeom(trait, g) + color_ok ? printstyled(io, nameof(typeof(g)); color = :cyan) : print(io, nameof(typeof(g))) + print(io, "(vertices=", verts, ", parts=", parts, ")") +end diff --git a/src/parsing.jl b/src/parsing.jl index 3ce7fb6..eac27c5 100644 --- a/src/parsing.jl +++ b/src/parsing.jl @@ -1,7 +1,61 @@ +# turn an XML.Node into a KMLFile by finding the element +function KMLFile(doc::XML.Node) + i = findfirst(x -> x.tag == "kml", XML.children(doc)) + isnothing(i) && error("No tag found in file.") + KML.KMLFile(map(KML.object, XML.children(doc[i]))) +end + +# ───────────────────────────────────────────────────────────────────────────── +# I/O glue: read/write KMLFile via XML +# ───────────────────────────────────────────────────────────────────────────── +# Internal helper: pull the element out of an XML.Document node +function _parse_kmlfile(doc::XML.Node) + i = findfirst(x -> x.tag == "kml", XML.children(doc)) + isnothing(i) && error("No tag found in file.") + xml_children = XML.children(doc[i]) + kml_children = Vector{Union{Node, KMLElement}}(undef, length(xml_children)) # Preallocate + for (idx, child_node) in enumerate(xml_children) + kml_children[idx] = object(child_node) # Populate + end + KMLFile(kml_children) +end + +# Read from any IO stream +function Base.read(io::IO, ::Type{KMLFile}) + doc = xmlread(io, Node) # parse into XML.Node + _parse_kmlfile(doc) +end + +# Read from a filename +function Base.read(path::AbstractString, ::Type{KMLFile}) + xmlread(path, Node) |> _parse_kmlfile +end + +# Parse from an in-memory string +Base.parse(::Type{KMLFile}, s::AbstractString) = _parse_kmlfile(xmlparse(s, Node)) + +# ───────────────────────────────────────────────────────────────────────────── +# write back out (XML.write) for any of our core types +# ───────────────────────────────────────────────────────────────────────────── + +# writable union for XML.write +const Writable = Union{KMLFile,KMLElement,XML.Node} + +function Base.write(io::IO, o::Writable; kw...) + xmlwrite(io, Node(o); kw...) +end + +function Base.write(path::AbstractString, o::Writable; kw...) + xmlwrite(path, Node(o); kw...) +end + +Base.write(o::Writable; kw...) = Base.write(stdout, o; kw...) + #-----------------------------------------------------------------------------# XML.Node ←→ KMLElement typetag(T) = replace(string(T), r"([a-zA-Z]*\.)" => "", "_" => ":") coordinate_string(x::Tuple) = join(x, ',') +coordinate_string(x::StaticArraysCore.SVector) = join(x, ',') coordinate_string(x::Vector) = join(coordinate_string.(x), '\n') # KMLElement → Node @@ -59,13 +113,15 @@ function object(node::XML.Node) return _object_slow(node) end -# original implementation, renamed -_object_slow(node::XML.Node) = begin +const KML_NAMES_SET = Set(names(KML; all=true, imported=true)) # Get all names in KML +const ENUM_NAMES_SET = Set(names(Enums; all=true)) # Get all names in Enums + +function _object_slow(node::XML.Node) sym = tagsym(node) - if sym in names(Enums, all = true) + if sym in ENUM_NAMES_SET return getproperty(Enums, sym)(XML.value(only(node))) end - if sym in names(KML) || sym == :Pair + if sym in KML_NAMES_SET || sym == :Pair T = getproperty(KML, sym) o = T() add_attributes!(o, node) @@ -74,20 +130,48 @@ _object_slow(node::XML.Node) = begin end return o end - nothing + return nothing # Ensure a return path if no conditions met end +const COORD_RE = r"[,\s]+" # one-time compile + function _parse_coordinates(txt::AbstractString) - nums = parse.(Float64, filter(!isempty, split(txt, r"[,\s]+"))) - - if mod(length(nums), 3) == 0 # lon‑lat‑alt triples - return [ SVector{3}(nums[i], nums[i+1], nums[i+2]) - for i = 1:3:length(nums) ] - elseif mod(length(nums), 2) == 0 # lon‑lat pairs - return [ SVector{2}(nums[i], nums[i+1]) - for i = 1:2:length(nums) ] + parts = split(txt, COORD_RE; keepempty=false) + len_parts = length(parts) + + if mod(len_parts, 3) == 0 + n_coords = len_parts ÷ 3 + # This assumes suggestion 1 (pre-allocation of result vector) is in place + result = Vector{SVector{3, Float64}}(undef, n_coords) + for i in 1:n_coords + offset = (i - 1) * 3 + + # Using Parsers.jl for parsing + # Parsers.parse will throw an error if parsing fails, which is usually + # desired for malformed coordinate data. + # It directly accepts SubString{String}, which `parts` contains. + x = Parsers.parse(Float64, parts[offset+1]) + y = Parsers.parse(Float64, parts[offset+2]) + z = Parsers.parse(Float64, parts[offset+3]) + + result[i] = SVector{3,Float64}(x, y, z) + end + return result + elseif mod(len_parts, 2) == 0 + n_coords = len_parts ÷ 2 + result = Vector{SVector{2, Float64}}(undef, n_coords) + for i in 1:n_coords + offset = (i - 1) * 2 + + x = Parsers.parse(Float64, parts[offset+1]) + y = Parsers.parse(Float64, parts[offset+2]) + + result[i] = SVector{2,Float64}(x, y) + end + return result else - error("Coordinate list length $(length(nums)) is not a multiple of 2 or 3") + # Consider making the error message more informative, e.g., include part of 'txt' + error("Coordinate list length $(len_parts) from string snippet '$(first(txt, 50))...' is not a multiple of 2 or 3") end end @@ -114,11 +198,11 @@ function add_element!(parent::Union{Object,KMLElement}, child::XML.Node) txt == "1" || lowercase(txt) == "true" elseif ftype <: Enums.AbstractKMLEnum ftype(txt) - # (b) the special coordinate string + # (b) the special coordinate string elseif fname === :coordinates vec = _parse_coordinates(txt) val = (ftype <: Union{Nothing,Tuple}) ? first(vec) : vec - # (c) fallback – let the generic helper take a stab + # (c) fallback – let the generic helper take a stab else autosetfield!(parent, fname, txt) return diff --git a/src/tables.jl b/src/tables.jl new file mode 100644 index 0000000..b0161f4 --- /dev/null +++ b/src/tables.jl @@ -0,0 +1,146 @@ +module TablesBridge +using Tables +import ..KML: KMLFile, read, Feature, Document, Folder, Placemark, Geometry, object +import XML: parse, Node +using Base.Iterators: flatten + +#────────────────────────────── helpers lifted from your old code ──────────────────────────────# +function _top_level_features(file::KMLFile)::Vector{Feature} + feats = Feature[c for c in file.children if c isa Feature] + if isempty(feats) + for c in file.children + if (c isa Document || c isa Folder) && c.Features !== nothing + append!(feats, c.Features) + end + end + end + feats +end + +function _determine_layers(features::Vector{Feature}) + if length(features) == 1 + f = features[1] + if f isa Document || f isa Folder + sub = (f.Features !== nothing ? f.Features : Feature[]) + return [x for x in sub if x isa Document || x isa Folder], Placemark[x for x in sub if x isa Placemark], f + else + return Feature[], (f isa Placemark ? [f] : Placemark[]), nothing + end + else + return [x for x in features if x isa Document || x isa Folder], + Placemark[x for x in features if x isa Placemark], + nothing + end +end + +function _select_layer(cont::Vector{Feature}, direct::Vector{Placemark}, parent, layer::Union{Nothing,String}) + if layer !== nothing + for c in cont + if c.name === layer + return c + end + end + if isempty(cont) && parent !== nothing && parent.name === layer + return parent + end + error("Layer \"$layer\" not found") + end + + opts, cand = String[], Any[] + for c in cont + push!(opts, c.name !== nothing ? c.name : "") + push!(cand, c) + end + if !isempty(direct) + push!(opts, "") + push!(cand, direct) + end + + if length(cand) ≤ 1 + return isempty(cand) ? nothing : cand[1] + end + + interactive = (stdin isa Base.TTY) && (stdout isa Base.TTY) && isinteractive() + if interactive + idx = request("Select a layer:", RadioMenu(opts; pagesize = min(10, length(opts)))) + idx == -1 && error("Selection cancelled") + cand[idx] + else + @warn "Multiple layers - picking first ($(opts[1])) automatically" + cand[1] + end +end + +#────────────────────────── streaming iterator over placemarks ──────────────────────────# +function _placemark_iterator(file::KMLFile, layer) + feats = _top_level_features(file) + cont, direct, parent = _determine_layers(feats) + sel = _select_layer(cont, direct, parent, layer) + return _iter_feat(sel) # Remove flatten() +end + +function _iter_feat(x) + if x isa Placemark + return (x for _ = 1:1) + elseif (x isa Document || x isa Folder) && x.Features !== nothing + return flatten(_iter_feat.(x.Features)) + elseif x isa AbstractVector{<:Feature} # Or more specifically AbstractVector{<:Placemark} + # If x is a vector of features (e.g., Placemarks), + # iterate over each feature and recursively call _iter_feat. + # This ensures that if it's a vector of Placemarks, each Placemark + # is properly processed by the 'x isa Placemark' case. + return flatten(_iter_feat.(x)) + else + return () # Fallback for any other type or empty collections + end +end + +#──────────────────────────── streaming PlacemarkTable type ────────────────────────────# +""" + PlacemarkTable(source; layer=nothing) + +A lazy, streaming Tables.jl table of the placemarks in a KML file. +You can call it either with a path or with an already‐loaded `KMLFile`. +""" +struct PlacemarkTable + file::KMLFile + layer::Union{Nothing,String} +end + +# two constructors: +PlacemarkTable(path::AbstractString; layer = nothing) = PlacemarkTable(read(path, KMLFile), layer) +PlacemarkTable(file::KMLFile; layer = nothing) = PlacemarkTable(file, layer) + +#──────────────────────────────── Tables.jl API ──────────────────────────────────# +Tables.istable(::Type{PlacemarkTable}) = true +Tables.rowaccess(::Type{PlacemarkTable}) = true + +Tables.schema(::PlacemarkTable) = Tables.Schema((:name, :description, :geometry), (String, String, Geometry)) + +function Tables.rows(tbl::PlacemarkTable) + it = _placemark_iterator(tbl.file, tbl.layer) + return ( + let pl = pl + ( + name = pl.name === nothing ? "" : pl.name, + description = pl.description === nothing ? "" : pl.description, + geometry = pl.Geometry, + ) + end for pl in it + ) +end + +Tables.istable(::Type{KMLFile}) = true +Tables.rowaccess(::Type{KMLFile}) = true + +function Tables.schema(k::KMLFile) + # use the same 3-column schema as for PlacemarkTable + return Tables.schema(PlacemarkTable(k)) +end + +function Tables.rows(k::KMLFile) + # delegate row iteration to the PlacemarkTable constructor + return Tables.rows(PlacemarkTable(k)) +end + +end # module TablesBridge diff --git a/src/types.jl b/src/types.jl new file mode 100644 index 0000000..e52bdaf --- /dev/null +++ b/src/types.jl @@ -0,0 +1,663 @@ +#------------------------------------------------------------------------------ +# types.jl – all KML data structures *without* GeoInterface extensions +#------------------------------------------------------------------------------ + +# ─── internal helpers / constants ──────────────────────────────────────────── +const Coord2 = SVector{2,Float64} +const Coord3 = SVector{3,Float64} + +const TAG_TO_TYPE = Dict{Symbol,DataType}() # XML tag => Julia type +const _FIELD_MAP_CACHE = IdDict{DataType,Dict{Symbol,Type}}() # reflect once, reuse + +macro def(name, definition) + quote + macro $(esc(name))() + esc($(Expr(:quote, definition))) + end + end +end + +@def altitude_mode_elements begin + altitudeMode::Union{Nothing,Enums.altitudeMode} = nothing + gx_altitudeMode::Union{Nothing,Enums.gx_altitudeMode} = nothing +end + +macro option(ex) + ex.head == :(::) || error("@option must annotate a field, e.g. f::T") + ex.args[2] = Expr(:curly, :Union, :Nothing, ex.args[2]) # T ➜ Union{Nothing,T} + :($(esc(ex)) = nothing) +end + +macro required(ex) + ex.head == :(::) || error("@required must annotate a field, e.g. f::T") + ex.args[2] = Expr(:curly, :Union, :Nothing, ex.args[2]) + :($(esc(ex)) = (@warn "Required field :$(ex.args[1]) initialised as nothing"; nothing)) +end + +name(T::Type) = replace(string(T), r"([a-zA-Z]*\.)" => "") # strip module prefix +name(x) = name(typeof(x)) + +function all_concrete_subtypes(T) + out = DataType[] + for S in subtypes(T) + isabstracttype(S) ? append!(out, all_concrete_subtypes(S)) : push!(out, S) + end + out +end +function all_abstract_subtypes(T) + out = filter(isabstracttype, subtypes(T)) + for S in copy(out) + append!(out, all_abstract_subtypes(S)) + end + out +end + +# ─── KMLElement base ───────────────────────────────────────────────────────── +abstract type KMLElement{attr_names} <: XML.AbstractXMLNode end +const NoAttributes = KMLElement{()} + +# printing (does *not* depend on GeoInterface) +function Base.show(io::IO, o::T) where {names,T<:KMLElement{names}} + printstyled(io, T; color = :light_cyan) + print(io, ": [") + show(io, Node(o)) + print(io, "]") +end + +# ─── XML interface helpers ─────────────────────────────────────────────────── +XML.tag(o::KMLElement) = name(o) +function XML.attributes(o::T) where {names,T<:KMLElement{names}} + OrderedDict(k => getfield(o, k) for k in names if !isnothing(getfield(o, k))) +end +XML.children(o::KMLElement) = XML.children(Node(o)) + +function typemap(::Type{T}) where {T<:KMLElement} + get!(_FIELD_MAP_CACHE, T) do + Dict(fieldnames(T) .=> Base.nonnothingtype.(fieldtypes(T))) + end +end +function typemap(o::KMLElement) + typemap(typeof(o)) +end + +Base.:(==)(a::T, b::T) where {T<:KMLElement} = all(getfield(a, f) == getfield(b, f) for f in fieldnames(T)) + +# ─── minimal "Enums" sub‑module (no external deps) ─────────────────────────── +module Enums +import ..NoAttributes +using XML +abstract type AbstractKMLEnum <: NoAttributes end +Base.show(io::IO, o::AbstractKMLEnum) = print(io, typeof(o), ": ", repr(o.value)) +Base.convert(::Type{T}, x::String) where {T<:AbstractKMLEnum} = T(x) +Base.string(o::AbstractKMLEnum) = o.value +macro kml_enum(T, vals...) + esc(quote + struct $T <: AbstractKMLEnum + value::String + function $T(v) + string(v) ∈ $(string.(vals)) || error("$(T) ∉ $(vals). Found: " * string(v)) + new(string(v)) + end + end + end) +end +@kml_enum altitudeMode clampToGround relativeToGround absolute +@kml_enum gx_altitudeMode relativeToSeaFloor clampToSeaFloor +@kml_enum refreshMode onChange onInterval onExpire +@kml_enum viewRefreshMode never onStop onRequest onRegion +@kml_enum shape rectangle cylinder sphere +@kml_enum gridOrigin lowerLeft upperLeft +@kml_enum displayMode default hide +@kml_enum listItemType check checkOffOnly checkHideChildren radioFolder +@kml_enum units fraction pixels insetPixels +@kml_enum itemIconState open closed error fetching0 fetching1 fetching2 +@kml_enum styleState normal highlight +@kml_enum colorMode normal random +@kml_enum flyToMode smooth bounce +end # module Enums + +# ─── KMLFile + core object hierarchy (NO GeoInterface code) ────────────────── +mutable struct KMLFile + children::Vector{Union{Node,KMLElement}} +end +KMLFile(content::KMLElement...) = KMLFile(collect(content)) +Base.push!(k::KMLFile, x::Union{Node,KMLElement}) = push!(k.children, x) + +function Base.show(io::IO, k::KMLFile) + print(io, "KMLFile ") + printstyled(io, '(', Base.format_bytes(Base.summarysize(k)), ')'; color = :light_black) +end + +function Node(k::KMLFile) + Node( + XML.Document, + nothing, + nothing, + nothing, + [ + Node(XML.Declaration, nothing, OrderedDict("version" => "1.0", "encoding" => "UTF-8")), + Node(XML.Element, "kml", OrderedDict("xmlns" => "http://earth.google.com/kml/2.2"), nothing, Node.(k.children)), + ], + ) +end + +Base.:(==)(a::KMLFile, b::KMLFile) = all(getfield(a,f) == getfield(b,f) for f in fieldnames(KMLFile)) + +#────────────────────────────────────────────────────────────────────────────── +# OBJECT / FEATURE HIERARCHY +# (everything that represents real KML elements – but *no* GeoInterface) +#────────────────────────────────────────────────────────────────────────────── + +#------------------------------------------------------------------------ +# Abstract roots +#------------------------------------------------------------------------ +abstract type Object <: KMLElement{(:id, :targetId)} end +abstract type Feature <: Object end +abstract type Overlay <: Feature end +abstract type Container <: Feature end +abstract type Geometry <: Object end +abstract type StyleSelector <: Object end +abstract type TimePrimitive <: Object end +abstract type AbstractView <: Object end +abstract type SubStyle <: Object end +abstract type ColorStyle <: SubStyle end +abstract type gx_TourPrimitive <: Object end + +#------------------------------------------------------------------------ +# helper macro for the common :id / :targetId pair +#------------------------------------------------------------------------ +@def object begin + @option id ::String + @option targetId ::String +end + +#────────────────────────────── UTILITY / SIMPLE SHARED COMPONENT NODES ───────────────────── +# Define these early as they are used by various KML elements like ScreenOverlay and IconStyle. + +# Represents KML elements like , , , etc., which share x, y, xunits, yunits attributes. +Base.@kwdef mutable struct hotSpot <: KMLElement{(:x, :y, :xunits, :yunits)} + @option x ::Float64 + @option y ::Float64 + @option xunits ::Enums.units + @option yunits ::Enums.units +end + +# Aliases for hotSpot, used in specific contexts (e.g., ScreenOverlay fields) +const overlayXY = hotSpot +const screenXY = hotSpot +const rotationXY = hotSpot +const size = hotSpot # KML for ScreenOverlay has x, y, xunits, yunits, matching hotSpot structure. + +#──────────────────── OBJECT‑LEVEL ELEMENTS (Reusable Components) ───────────────── +# These are general KML Objects that can be children of other elements or define properties. +# They are not Features or Geometries themselves but are often used by them. + +Base.@kwdef mutable struct Link <: Object + @object + @option href ::String + @option refreshMode ::Enums.refreshMode + @option refreshInterval ::Float64 + @option viewRefreshMode ::Enums.viewRefreshMode + @option viewRefreshTime ::Float64 + @option viewBoundScale ::Float64 + @option viewFormat ::String + @option httpQuery ::String +end + +Base.@kwdef mutable struct Icon <: Object + @object + @option href ::String + @option refreshMode ::Enums.refreshMode + @option refreshInterval::Float64 + @option viewRefreshMode::Enums.viewRefreshMode + @option viewRefreshTime::Float64 + @option viewBoundScale ::Float64 + @option viewFormat ::String + @option httpQuery ::String +end + +Base.@kwdef mutable struct Orientation <: Object + @object + @option heading::Float64 + @option tilt ::Float64 + @option roll ::Float64 +end + +Base.@kwdef mutable struct Location <: Object + @object + @option longitude::Float64 + @option latitude ::Float64 + @option altitude ::Float64 +end + +Base.@kwdef mutable struct Scale <: Object + @object + @option x::Float64 + @option y::Float64 + @option z::Float64 +end + +Base.@kwdef mutable struct Lod <: Object + @object + minLodPixels::Int = 128 + @option maxLodPixels ::Int + @option minFadeExtent ::Int + @option maxFadeExtent ::Int +end + +Base.@kwdef mutable struct LatLonBox <: Object + @object + north::Float64 = 0 + south::Float64 = 0 + east::Float64 = 0 + west::Float64 = 0 + @option rotation::Float64 +end + +Base.@kwdef mutable struct LatLonAltBox <: Object + @object + north::Float64 = 0 + south::Float64 = 0 + east::Float64 = 0 + west::Float64 = 0 + @option minAltitude::Float64 + @option maxAltitude::Float64 + @altitude_mode_elements +end + +Base.@kwdef mutable struct Region <: Object + @object + LatLonAltBox::LatLonAltBox = LatLonAltBox() + @option Lod::Lod +end + +Base.@kwdef mutable struct gx_LatLonQuad <: Object + @object + coordinates::Vector{Coord2} = [(0, 0), (0, 0), (0, 0), (0, 0)] + gx_LatLonQuad(id, targetId, c) = (@assert length(c) == 4; new(id, targetId, c)) +end + +#──────────────────────────── SUBSTYLES / COLOURS ────────────────────────── +# SubStyle elements are components of