aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Cargo.toml20
-rw-r--r--src/LiberationMono.ttfbin0 -> 313408 bytes
-rw-r--r--src/api/mod.rs205
-rw-r--r--src/api/professions.rs36
-rw-r--r--src/api/skills.rs24
-rw-r--r--src/api/specializations.rs34
-rw-r--r--src/api/traits.rs40
-rw-r--r--src/bt.rs160
-rw-r--r--src/cache.rs78
-rw-r--r--src/main.rs252
-rw-r--r--src/minor_trait_mask.pngbin0 -> 4287 bytes
-rw-r--r--src/render.rs325
13 files changed, 1175 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..05dc674
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "kondou"
+version = "0.1.0"
+authors = ["Daniel <kingdread@gmx.de>"]
+edition = "2018"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+image = "0.22"
+imageproc = "0.19"
+rusttype = "0.7"
+reqwest = "0.9"
+serde = "1.0"
+serde_json = "1.0"
+clap = "2.33"
+xdg = "2.2.0"
+quick-error = "1.2.0"
+itertools = "0.8.0"
+md5 = "0.7"
diff --git a/src/LiberationMono.ttf b/src/LiberationMono.ttf
new file mode 100644
index 0000000..1a39bc7
--- /dev/null
+++ b/src/LiberationMono.ttf
Binary files differ
diff --git a/src/api/mod.rs b/src/api/mod.rs
new file mode 100644
index 0000000..6e1b457
--- /dev/null
+++ b/src/api/mod.rs
@@ -0,0 +1,205 @@
+//! This module is responsible for accessing the Guild Wars 2 API.
+//!
+//! It contains `Deserialize`able definitions for the required API responses, as well as a wrapper
+//! around the HTTP library. Note that only the required fields are modelled here, which means this
+//! does not provide a full-featured mapping for all API types.
+pub mod professions;
+pub mod skills;
+pub mod specializations;
+pub mod traits;
+
+pub use self::{
+ professions::Profession, skills::Skill, specializations::Specialization, traits::Trait,
+};
+
+use image::DynamicImage;
+use itertools::Itertools;
+use reqwest::{Client, Url};
+use serde::{de::DeserializeOwned, Serialize};
+use std::path::Path;
+
+use super::cache::{Cache, CacheError};
+
+/// The base URL of the official Guild Wars 2 API.
+const BASE_URL: &str = "https://api.guildwars2.com/v2/";
+
+quick_error! {
+ #[derive(Debug)]
+ pub enum ApiError {
+ SerializationError(err: serde_json::Error) {
+ cause(err)
+ from()
+ }
+ CacheError(err: CacheError) {
+ cause(err)
+ from()
+ }
+ HttpError(err: reqwest::Error) {
+ cause(err)
+ from()
+ }
+ ImageError(err: image::ImageError) {
+ cause(err)
+ from()
+ }
+ }
+}
+
+/// Trait for API objects that have an ID.
+///
+/// This is used by [`Api`](struct.Api.html) to properly retrieve and cache objects.
+trait HasId {
+ type Id: ToString + Eq + Clone;
+ fn get_id(&self) -> Self::Id;
+}
+
+/// The main API access struct.
+///
+/// This takes care of caching given the provided cache implementation, as well as keeping a HTTP
+/// connection pool around to re-use.
+pub struct Api {
+ cache: Box<dyn Cache>,
+ base_url: Url,
+ client: Client,
+}
+
+/// API access for the GW2 api.
+impl Api {
+ /// Create a new API instance with the given cache underlying.
+ pub fn new<C: 'static + Cache>(cache: C) -> Api {
+ Api {
+ cache: Box::new(cache),
+ base_url: Url::parse(BASE_URL).unwrap(),
+ client: Client::new(),
+ }
+ }
+
+ /// Combines the given endpoint with the `base_url` of this API.
+ fn make_url(&self, endpoint: &str) -> Url {
+ self.base_url.join(endpoint).expect("Invalid API endpoint")
+ }
+
+ /// Get and deserialize a cached value.
+ fn get_cached<T, P>(&mut self, name: P) -> Result<Option<T>, ApiError>
+ where
+ T: DeserializeOwned,
+ P: AsRef<Path>,
+ {
+ match self.cache.get(name.as_ref())? {
+ Some(data) => Ok(serde_json::from_slice(&data)?),
+ None => Ok(None),
+ }
+ }
+
+ /// Serialize and store a value in the cache.
+ fn save_cached<T, P>(&mut self, name: P, value: &T) -> Result<(), ApiError>
+ where
+ T: Serialize,
+ P: AsRef<Path>,
+ {
+ self.cache
+ .store(name.as_ref(), &serde_json::to_vec(value)?)?;
+ Ok(())
+ }
+
+ /// Retrieves a list of elements by their ID.
+ ///
+ /// This function first checks the cache if elements can be found there, and otherwise hits the
+ /// API.
+ fn get_multiple_cached<R>(
+ &mut self,
+ endpoint: &str,
+ cache_prefix: &str,
+ ids: &[R::Id],
+ ) -> Result<Vec<R>, ApiError>
+ where
+ R: HasId + DeserializeOwned + Serialize,
+ {
+ let mut result: Vec<R> = Vec::new();
+ let mut api_ids: Vec<R::Id> = Vec::new();
+
+ for id in ids {
+ let cache_path = format!("{}{}", cache_prefix, id.to_string());
+ match self.get_cached(cache_path)? {
+ Some(cached) => result.push(cached),
+ None => api_ids.push(id.clone()),
+ }
+ }
+
+ if api_ids.is_empty() {
+ return Ok(result);
+ }
+
+ let url = self.make_url(endpoint);
+ let api_arg = api_ids.iter().map(ToString::to_string).join(",");
+ let resp: Vec<R> = self
+ .client
+ .get(url)
+ .query(&[("ids", api_arg)])
+ .send()?
+ .json()?;
+ for result in &resp {
+ let cache_path = format!("{}{}", cache_prefix, result.get_id().to_string());
+ self.save_cached(cache_path, result)?;
+ }
+ result.extend(resp.into_iter());
+
+ Ok(result)
+ }
+
+ /// Retrieve a list of all professions using the professions endpoint.
+ pub fn get_profession_ids(&mut self) -> Result<Vec<String>, ApiError> {
+ if let Some(cached) = self.get_cached("profession_ids")? {
+ return Ok(cached);
+ }
+ let url = self.make_url("professions");
+ let resp = self.client.get(url).send()?.json()?;
+ self.save_cached("profession_ids", &resp)?;
+ Ok(resp)
+ }
+
+ /// Retrieve detailed information about the given professions.
+ ///
+ /// Professions that are found in the cache are taken from there. Therefore, the order of the
+ /// return vector is not guaranteed to be the same as the input order.
+ pub fn get_professions(&mut self, ids: &[String]) -> Result<Vec<Profession>, ApiError> {
+ self.get_multiple_cached("professions", "professions/", ids)
+ }
+
+ /// Retrieve detailed information about the given skills.
+ ///
+ /// Skills that are found in the cache are taken from there.
+ pub fn get_skills(&mut self, ids: &[u32]) -> Result<Vec<Skill>, ApiError> {
+ self.get_multiple_cached("skills", "skills/", ids)
+ }
+
+ /// Retrieve detailed information about the given specializations.
+ ///
+ /// Specializations that are found in the cache are taken from there.
+ pub fn get_specializations(&mut self, ids: &[u32]) -> Result<Vec<Specialization>, ApiError> {
+ self.get_multiple_cached("specializations", "specializations/", ids)
+ }
+
+ /// Retrieve detailed information about the given traits.
+ ///
+ /// Traits that are found in the cache are taken from there.
+ pub fn get_traits(&mut self, ids: &[u32]) -> Result<Vec<Trait>, ApiError> {
+ self.get_multiple_cached("traits", "traits/", ids)
+ }
+
+ /// Loads the image from the given URL.
+ ///
+ /// This automatically caches and also decodes the resulting data.
+ pub fn get_image(&mut self, url: &str) -> Result<DynamicImage, ApiError> {
+ let hashed_url = format!("images/{:x}", md5::compute(url.as_bytes()));
+ if let Some(data) = self.cache.get(Path::new(&hashed_url))? {
+ return Ok(image::load_from_memory(&data)?);
+ }
+
+ let mut img = Vec::new();
+ self.client.get(url).send()?.copy_to(&mut img)?;
+
+ self.cache.store(Path::new(&hashed_url), &img)?;
+ Ok(image::load_from_memory(&img)?)
+ }
+}
diff --git a/src/api/professions.rs b/src/api/professions.rs
new file mode 100644
index 0000000..2716a1a
--- /dev/null
+++ b/src/api/professions.rs
@@ -0,0 +1,36 @@
+//! Struct definitions for the professions API endpoint.
+//!
+//! * [Example](https://api.guildwars2.com/v2/professions/Engineer)
+//! * [Wiki](https://wiki.guildwars2.com/wiki/API:2/professions)
+
+use super::HasId;
+use serde::{Deserialize, Serialize};
+
+#[derive(Deserialize, Serialize, Debug, Clone)]
+pub struct Profession {
+ /// The profession id.
+ pub id: String,
+ /// The name of the profession.
+ pub name: String,
+ /// List of specialization ids.
+ pub specializations: Vec<u32>,
+ /// List of skills.
+ pub skills: Vec<Skill>,
+}
+
+impl HasId for Profession {
+ type Id = String;
+ fn get_id(&self) -> String {
+ self.id.clone()
+ }
+}
+
+#[derive(Deserialize, Serialize, Debug, Clone)]
+pub struct Skill {
+ /// The id of the skill.
+ pub id: u32,
+ /// The skill bar slot that this skill can be used in.
+ pub slot: String,
+ #[serde(rename = "type")]
+ pub typ: String,
+}
diff --git a/src/api/skills.rs b/src/api/skills.rs
new file mode 100644
index 0000000..9c692e1
--- /dev/null
+++ b/src/api/skills.rs
@@ -0,0 +1,24 @@
+//! Struct definitions for the skills API endpoint.
+//!
+//! * [Example](https://api.guildwars2.com/v2/skills/14375)
+//! * [Wiki](https://wiki.guildwars2.com/wiki/API:2/skills)
+
+use super::HasId;
+use serde::{Deserialize, Serialize};
+
+#[derive(Deserialize, Serialize, Debug, Clone)]
+pub struct Skill {
+ /// The skill id.
+ pub id: u32,
+ /// The skill name.
+ pub name: String,
+ /// A URL to an icon of the skill.
+ pub icon: String,
+}
+
+impl HasId for Skill {
+ type Id = u32;
+ fn get_id(&self) -> u32 {
+ self.id
+ }
+}
diff --git a/src/api/specializations.rs b/src/api/specializations.rs
new file mode 100644
index 0000000..97fc289
--- /dev/null
+++ b/src/api/specializations.rs
@@ -0,0 +1,34 @@
+//! Struct definitions for the specializations API endpoint.
+//!
+//! * [Example](https://api.guildwars2.com/v2/specializations/1)
+//! * [Wiki](https://wiki.guildwars2.com/wiki/API:2/specializations)
+
+use super::HasId;
+use serde::{Deserialize, Serialize};
+
+#[derive(Deserialize, Serialize, Debug, Clone)]
+pub struct Specialization {
+ /// The specialization's ID.
+ pub id: u32,
+ /// The name of the specialization.
+ pub name: String,
+ /// The profession that this specialization belongs to.
+ pub profession: String,
+ /// `true` if this specialization is an elite specialization, `false` otherwise.
+ pub elite: bool,
+ /// A URL to an icon of the specialization.
+ pub icon: String,
+ /// An URL to the background image of the specialization.
+ pub background: String,
+ /// Contains a list of IDs specifying the minor traits in the specialization.
+ pub minor_traits: Vec<u32>,
+ /// Contains a list of IDs specifying the major traits in the specialization.
+ pub major_traits: Vec<u32>,
+}
+
+impl HasId for Specialization {
+ type Id = u32;
+ fn get_id(&self) -> u32 {
+ self.id
+ }
+}
diff --git a/src/api/traits.rs b/src/api/traits.rs
new file mode 100644
index 0000000..194d061
--- /dev/null
+++ b/src/api/traits.rs
@@ -0,0 +1,40 @@
+//! Struct definitions for the traits API endpoint.
+//!
+//! * [Example](https://api.guildwars2.com/v2/traits/214)
+//! * [Wiki](https://wiki.guildwars2.com/wiki/API:2/traits)
+
+use super::HasId;
+use serde::{Deserialize, Serialize};
+
+#[derive(Deserialize, Serialize, Debug, Clone)]
+pub struct Trait {
+ /// The trait id.
+ pub id: u32,
+ /// The trait name.
+ pub name: String,
+ /// The trait's icon URL.
+ pub icon: String,
+ /// The trait description.
+ pub description: String,
+ /// The id of the specialization this trait belongs to.
+ pub specialization: u32,
+ /// The trait's tier, as a value from 1-3.
+ ///
+ /// Elite specializations also contain a tier 0 minor trait, describing which weapon the elite
+ /// specialization gains access to.
+ pub tier: u32,
+ pub slot: Slot,
+}
+
+impl HasId for Trait {
+ type Id = u32;
+ fn get_id(&self) -> u32 {
+ self.id
+ }
+}
+
+#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Copy, Clone)]
+pub enum Slot {
+ Major,
+ Minor,
+}
diff --git a/src/bt.rs b/src/bt.rs
new file mode 100644
index 0000000..3485448
--- /dev/null
+++ b/src/bt.rs
@@ -0,0 +1,160 @@
+use super::api::{Skill, Specialization};
+use std::{fmt, str::FromStr};
+
+/// The profession of the template.
+///
+/// Can be cast to an `u8` to get the right ID for building chat links.
+#[repr(u8)]
+#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)]
+pub enum Profession {
+ Guardian = 1,
+ Warrior = 2,
+ Engineer = 3,
+ Ranger = 4,
+ Thief = 5,
+ Elementalist = 6,
+ Mesmer = 7,
+ Necromancer = 8,
+ Revenant = 9,
+}
+
+impl fmt::Display for Profession {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ fmt::Debug::fmt(&self, f)
+ }
+}
+
+impl FromStr for Profession {
+ type Err = ();
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "Guardian" => Ok(Profession::Guardian),
+ "Warrior" => Ok(Profession::Warrior),
+ "Engineer" => Ok(Profession::Engineer),
+ "Ranger" => Ok(Profession::Ranger),
+ "Thief" => Ok(Profession::Thief),
+ "Elementalist" => Ok(Profession::Elementalist),
+ "Mesmer" => Ok(Profession::Mesmer),
+ "Necromancer" => Ok(Profession::Necromancer),
+ "Revenant" => Ok(Profession::Revenant),
+ _ => Err(()),
+ }
+ }
+}
+
+/// Represents the selected trait.
+#[repr(u8)]
+#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)]
+pub enum TraitChoice {
+ None = 0,
+ Top = 1,
+ Middle = 2,
+ Bottom = 3,
+}
+
+impl FromStr for TraitChoice {
+ type Err = ();
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let lower = s.to_lowercase();
+ match &lower as &str {
+ "" | "none" => Ok(TraitChoice::None),
+ "top" => Ok(TraitChoice::Top),
+ "mid" | "middle" => Ok(TraitChoice::Middle),
+ "bot" | "bottom" => Ok(TraitChoice::Bottom),
+ _ => Err(()),
+ }
+ }
+}
+
+/// Represents a traitline.
+///
+/// A traitline consists of the chosen specialization including 3 possible trait choices.
+pub type Traitline = (Specialization, [TraitChoice; 3]);
+
+pub const SKILL_COUNT: usize = 5;
+pub const TRAITLINE_COUNT: usize = 3;
+
+/// Represents a build template.
+///
+/// This struct is made with the same limitations as the game imposes. That is, even though the
+/// renderer can support more than three traitlines, this template will only allow you to take
+/// three.
+#[derive(Debug)]
+pub struct BuildTemplate {
+ /// Profession of this build.
+ profession: Profession,
+ /// The skills of the build.
+ ///
+ /// Each slot can either contain a slot or be empty. A maximum of 5 skills are allowed (heal, 3
+ /// utilities and elite).
+ skills: [Option<Skill>; SKILL_COUNT],
+ /// The traitlines of the build.
+ traitlines: [Option<Traitline>; TRAITLINE_COUNT],
+}
+
+impl BuildTemplate {
+ /// Returns a template without any skills or traitlines.
+ pub fn empty(profession: Profession) -> BuildTemplate {
+ BuildTemplate {
+ profession,
+ skills: [None, None, None, None, None],
+ traitlines: [None, None, None],
+ }
+ }
+
+ /// Creates a template with the given skills and traitlines.
+ ///
+ /// If there are more than 5 skills or 3 traitlines given, the function will return `None`.
+ pub fn new(
+ profession: Profession,
+ skills: &[Skill],
+ traitlines: &[Traitline],
+ ) -> Option<BuildTemplate> {
+ if skills.len() > SKILL_COUNT {
+ return None;
+ }
+ if traitlines.len() > TRAITLINE_COUNT {
+ return None;
+ }
+ let mut skill_array = [None, None, None, None, None];
+ for (i, skill) in skills.iter().enumerate() {
+ skill_array[i] = Some(skill.clone());
+ }
+ let mut trait_array = [None, None, None];
+ for (i, traitline) in traitlines.iter().enumerate() {
+ trait_array[i] = Some(traitline.clone());
+ }
+ Some(BuildTemplate {
+ profession,
+ skills: skill_array,
+ traitlines: trait_array,
+ })
+ }
+
+ /// Returns the profession of this build.
+ pub fn profession(&self) -> Profession {
+ self.profession
+ }
+
+ /// Returns the skills of this build.
+ pub fn skills(&self) -> &[Option<Skill>] {
+ &self.skills
+ }
+
+ /// Returns the number of actually equipped skills.
+ pub fn skill_count(&self) -> u32 {
+ self.skills.iter().filter(|x| x.is_some()).count() as u32
+ }
+
+ /// Returns the traitlines of this build.
+ pub fn traitlines(&self) -> &[Option<Traitline>] {
+ &self.traitlines
+ }
+
+ /// Returns the number of actually equipped specializations.
+ pub fn traitline_count(&self) -> u32 {
+ self.traitlines.iter().filter(|x| x.is_some()).count() as u32
+ }
+}
diff --git a/src/cache.rs b/src/cache.rs
new file mode 100644
index 0000000..f64b45a
--- /dev/null
+++ b/src/cache.rs
@@ -0,0 +1,78 @@
+//! Caching support to prevent hitting the API a lot.
+use std::fs::File;
+use std::io::prelude::*;
+use std::path::Path;
+
+use xdg::BaseDirectories;
+
+use super::APP_NAME;
+
+quick_error! {
+ #[derive(Debug)]
+ pub enum CacheError {
+ Io(err: std::io::Error) {
+ cause(err)
+ from()
+ }
+ }
+}
+
+/// A generic cache.
+pub trait Cache {
+ fn store(&mut self, path: &Path, data: &[u8]) -> Result<(), CacheError>;
+ fn get(&mut self, path: &Path) -> Result<Option<Vec<u8>>, CacheError>;
+}
+
+/// A cache that stores files in the XDG specified cache location.
+pub struct FileCache {
+ dirs: BaseDirectories,
+}
+
+impl FileCache {
+ /// Create a new file backed cache.
+ pub fn new() -> Self {
+ let dirs = BaseDirectories::with_prefix(APP_NAME).unwrap();
+ FileCache { dirs }
+ }
+}
+
+impl Cache for FileCache {
+ fn store(&mut self, path: &Path, data: &[u8]) -> Result<(), CacheError> {
+ let cache_path = self.dirs.place_cache_file(path).unwrap();
+ let mut f = File::create(cache_path)?;
+ f.write_all(data)?;
+ Ok(())
+ }
+
+ fn get(&mut self, path: &Path) -> Result<Option<Vec<u8>>, CacheError> {
+ let cache_path = self.dirs.find_cache_file(path);
+ match cache_path {
+ Some(p) => {
+ let mut f = File::open(p)?;
+ let mut buffer = Vec::new();
+ f.read_to_end(&mut buffer)?;
+ Ok(Some(buffer))
+ }
+ None => Ok(None),
+ }
+ }
+}
+
+/// A cache that does nothing.
+struct NoopCache;
+
+impl NoopCache {
+ pub fn new() -> Self {
+ NoopCache
+ }
+}
+
+impl Cache for NoopCache {
+ fn store(&mut self, _path: &Path, _data: &[u8]) -> Result<(), CacheError> {
+ Ok(())
+ }
+
+ fn get(&mut self, _path: &Path) -> Result<Option<Vec<u8>>, CacheError> {
+ Ok(None)
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..9ffa1eb
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,252 @@
+extern crate clap;
+extern crate image;
+extern crate imageproc;
+extern crate itertools;
+extern crate md5;
+extern crate reqwest;
+extern crate rusttype;
+extern crate xdg;
+#[macro_use]
+extern crate quick_error;
+
+use std::error::Error as StdError;
+use std::fmt;
+
+mod api;
+mod bt;
+mod cache;
+mod render;
+
+use clap::{App, Arg, ArgMatches};
+
+use api::{Api, Profession, Skill};
+use bt::{BuildTemplate, TraitChoice, Traitline};
+
+const APP_NAME: &str = "kondou";
+
+#[derive(Debug, Clone)]
+enum Error {
+ ProfessionNotFound(String),
+ SkillIdNotFound(u32),
+ SkillNotFound(String),
+ SpecializationNotFound(String),
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match *self {
+ Error::ProfessionNotFound(ref name) => {
+ write!(f, "the profession '{}' could not be found", name)
+ }
+ Error::SkillIdNotFound(id) => write!(f, "the skill with the ID '{}' was not found", id),
+ Error::SkillNotFound(ref name) => {
+ write!(f, "the skill with the name '{}' was not found", name)
+ }
+ Error::SpecializationNotFound(ref name) => write!(
+ f,
+ "the specialization with the name '{}' was not found",
+ name
+ ),
+ }
+ }
+}
+
+impl StdError for Error {}
+
+type MainResult<T> = Result<T, Box<dyn StdError>>;
+
+fn find_profession(api: &mut Api, name: &str) -> MainResult<Profession> {
+ let profession_ids = api.get_profession_ids()?;
+ let lower_name = name.to_lowercase();
+ let profession_id = profession_ids
+ .iter()
+ .find(|id| id.to_lowercase() == lower_name)
+ .ok_or_else(|| Error::ProfessionNotFound(name.to_owned()))?
+ .clone();
+
+ Ok(api.get_professions(&[profession_id])?.remove(0))
+}
+
+fn resolve_skill(api: &mut Api, profession: &Profession, text: &str) -> MainResult<Skill> {
+ // Try it as an ID first
+ let numeric = text.parse::<u32>();
+ if let Ok(num_id) = numeric {
+ let exists = profession.skills.iter().any(|s| s.id == num_id);
+ if exists {
+ return Ok(api.get_skills(&[num_id])?.remove(0));
+ } else {
+ return Err(Error::SkillIdNotFound(num_id).into());
+ }
+ }
+
+ // Check all names of the profession skills
+ let all_ids = profession.skills.iter().map(|s| s.id).collect::<Vec<_>>();
+ let all_skills = api.get_skills(&all_ids)?;
+ let lower_text = text.to_lowercase();
+ all_skills
+ .into_iter()
+ .find(|s| s.name.to_lowercase().contains(&lower_text))
+ .ok_or_else(|| Error::SkillNotFound(text.to_owned()).into())
+}
+
+fn resolve_traitline(api: &mut Api, profession: &Profession, text: &str) -> MainResult<Traitline> {
+ let parts = text.split(':').collect::<Vec<_>>();
+ assert_eq!(
+ parts.len(),
+ 4,
+ "invalid text format passed to resolve_traitline"
+ );
+
+ let name = parts[0];
+ let lower_name = name.to_lowercase();
+ let spec = api
+ .get_specializations(&profession.specializations)?
+ .into_iter()
+ .find(|s| s.name.to_lowercase() == lower_name)
+ .ok_or_else(|| Error::SpecializationNotFound(name.to_owned()))?;
+
+ let mut choices = [TraitChoice::None; 3];
+ for (i, text_choice) in parts.iter().skip(1).enumerate() {
+ let choice = text_choice
+ .parse()
+ .expect("Argument validation failed us, there is an invalid value here");
+ choices[i] = choice;
+ }
+ Ok((spec, choices))
+}
+
+fn run_searching(api: &mut Api, matches: &ArgMatches) -> MainResult<BuildTemplate> {
+ let requested_profession = matches
+ .value_of("profession")
+ .expect("clap handles missing argument");
+
+ let profession = find_profession(api, requested_profession)?;
+
+ let skills = matches
+ .values_of("skill")
+ .map(Iterator::collect::<Vec<_>>)
+ .unwrap_or_default()
+ .into_iter()
+ .map(|s| resolve_skill(api, &profession, s))
+ .collect::<Result<Vec<_>, _>>()?;
+
+ let traitlines = matches
+ .values_of("traitline")
+ .map(Iterator::collect::<Vec<_>>)
+ .unwrap_or_default()
+ .into_iter()
+ .map(|t| resolve_traitline(api, &profession, t))
+ .collect::<Result<Vec<_>, _>>()?;
+
+ assert!(skills.len() <= bt::SKILL_COUNT, "got too many skills");
+ assert!(
+ traitlines.len() <= bt::TRAITLINE_COUNT,
+ "got too many traitlines"
+ );
+
+ let build = BuildTemplate::new(
+ profession
+ .name
+ .parse()
+ .expect("Profession object has unparseable name"),
+ &skills,
+ &traitlines,
+ )
+ .expect("BuildTemplate could not be constructed");
+
+ Ok(build)
+}
+
+fn validate_traitline_format(input: String) -> Result<(), String> {
+ let parts = input.split(':').collect::<Vec<_>>();
+ if parts.len() != 4 {
+ return Err("traitline format is line:trait_1:trait_2:trait_3".to_owned());
+ }
+
+ for part in parts.iter().skip(1) {
+ let parsed = part.parse::<TraitChoice>();
+ if parsed.is_err() {
+ return Err(format!(
+ "{} is not a valid trait. Use top, middle or bottom.",
+ part
+ ));
+ }
+ }
+
+ Ok(())
+}
+
+fn run() -> MainResult<()> {
+ let matches = App::new(APP_NAME)
+ .version("0.1")
+ .author("Daniel <kingdread@gmx.de>")
+ .about("Renders GW2 skills and traits.")
+ .arg(
+ Arg::with_name("profession")
+ .help("Selects which profession to use.")
+ .required_unless("chatlink"),
+ )
+ .arg(
+ Arg::with_name("skill")
+ .help("Selects a skill based on either the name or the ID.")
+ .takes_value(true)
+ .number_of_values(1)
+ .long("skill")
+ .short("s")
+ .multiple(true)
+ .max_values(bt::SKILL_COUNT as u64),
+ )
+ .arg(
+ Arg::with_name("traitline")
+ .help("Selects a traitline.")
+ .takes_value(true)
+ .number_of_values(1)
+ .long("traitline")
+ .short("t")
+ .multiple(true)
+ .validator(validate_traitline_format)
+ .max_values(bt::TRAITLINE_COUNT as u64),
+ )
+ .arg(
+ Arg::with_name("chatlink")
+ .help("Specifies a chat link to parse.")
+ .short("c")
+ .long("chatlink")
+ .takes_value(true)
+ .conflicts_with_all(&["profession", "skill"]),
+ )
+ .arg(
+ Arg::with_name("outfile")
+ .help("Specifies the output filename")
+ .short("o")
+ .long("outfile")
+ .default_value("buildtemplate.png")
+ .takes_value(true),
+ )
+ .get_matches();
+
+ let mut api = Api::new(cache::FileCache::new());
+ let build = match matches.is_present("chatlink") {
+ false => run_searching(&mut api, &matches)?,
+ true => unimplemented!(),
+ };
+
+ let mut renderer = render::Renderer::new(&mut api, Default::default());
+ let img = renderer.render_buildtemplate(&build).unwrap();
+ let filename = matches.value_of("outfile").unwrap();
+ img.save(filename)?;
+
+ Ok(())
+}
+
+fn main() {
+ let result = run();
+ if let Err(e) = result {
+ eprintln!("[Error] {}", e);
+ let mut source = e.source();
+ while let Some(s) = source {
+ eprintln!(" caused by {}", s);
+ source = s.source();
+ }
+ }
+}
diff --git a/src/minor_trait_mask.png b/src/minor_trait_mask.png
new file mode 100644
index 0000000..4fed74d
--- /dev/null
+++ b/src/minor_trait_mask.png
Binary files differ
diff --git a/src/render.rs b/src/render.rs
new file mode 100644
index 0000000..77a847f
--- /dev/null
+++ b/src/render.rs
@@ -0,0 +1,325 @@
+use super::api::{Api, ApiError, Skill, Specialization, Trait};
+use super::bt::{BuildTemplate, TraitChoice, Traitline};
+use image::{imageops, DynamicImage, GenericImage, GenericImageView, ImageBuffer, Rgba, RgbaImage};
+use imageproc::drawing::{self, Point};
+use rusttype::{Font, Scale, SharedBytes};
+
+quick_error! {
+ #[derive(Debug)]
+ pub enum RenderError {
+ ApiError(err: ApiError) {
+ cause(err)
+ from()
+ }
+ ImageError(err: image::ImageError) {
+ cause(err)
+ from()
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct RenderOptions {
+ skill_size: u32,
+ traitline_height: u32,
+ traitline_width: u32,
+ traitline_brightness: i32,
+ traitline_x_offset: u32,
+ trait_size: u32,
+ line_color: Rgba<u8>,
+ line_height: u32,
+ font: Font<'static>,
+ text_color: Rgba<u8>,
+ text_size: u32,
+ background_color: Rgba<u8>,
+ render_specialization_names: bool,
+ skill_offset: u32,
+}
+
+impl Default for RenderOptions {
+ fn default() -> Self {
+ RenderOptions {
+ skill_size: 100,
+ traitline_height: 160,
+ traitline_width: 550,
+ traitline_brightness: 100,
+ traitline_x_offset: 0,
+ trait_size: 30,
+ line_color: Rgba([0, 0, 0, 255]),
+ line_height: 4,
+ font: Font::from_bytes(SharedBytes::ByRef(include_bytes!("LiberationMono.ttf")))
+ .expect("Invalid font data for default font"),
+ text_color: Rgba([0, 0, 0, 255]),
+ text_size: 20,
+ background_color: Rgba([0, 0, 0, 0]),
+ render_specialization_names: true,
+ skill_offset: 30,
+ }
+ }
+}
+
+const BG_CROP_HEIGHT: u32 = 160;
+const BG_CROP_WIDTH: u32 = 550;
+
+pub struct Renderer<'r> {
+ api: &'r mut Api,
+ options: RenderOptions,
+ minor_mask: DynamicImage,
+}
+
+impl<'r> Renderer<'r> {
+ /// Create a new renderer using the given API.
+ pub fn new(api: &mut Api, options: RenderOptions) -> Renderer<'_> {
+ let minor_mask = image::load_from_memory(include_bytes!("minor_trait_mask.png"))
+ .expect("Mask image could not be loaded")
+ .resize(
+ options.trait_size,
+ options.trait_size,
+ imageops::FilterType::CatmullRom,
+ );
+ Renderer {
+ api,
+ options,
+ minor_mask,
+ }
+ }
+
+ pub fn render_skills(&mut self, skills: &[Option<Skill>]) -> Result<RgbaImage, RenderError> {
+ let mut buffer = ImageBuffer::new(
+ skills.len() as u32 * self.options.skill_size,
+ self.options.skill_size,
+ );
+ for (i, skill) in skills.iter().enumerate() {
+ if let Some(skill) = skill {
+ let img = self.api.get_image(&skill.icon)?.resize(
+ self.options.skill_size,
+ self.options.skill_size,
+ imageops::FilterType::CatmullRom,
+ );
+ buffer.copy_from(&img, i as u32 * self.options.skill_size, 0);
+ }
+ }
+ Ok(buffer)
+ }
+
+ fn render_minor_trait(
+ &mut self,
+ buffer: &mut RgbaImage,
+ minor: &Trait,
+ ) -> Result<(), RenderError> {
+ let minor_img = self.api.get_image(&minor.icon)?.resize(
+ self.options.trait_size,
+ self.options.trait_size,
+ imageops::FilterType::CatmullRom,
+ );
+ let minor_img = imageproc::map::map_pixels(&minor_img, |x, y, p| {
+ let alpha = self.minor_mask.get_pixel(x, y)[3];
+ Rgba([p[0], p[1], p[2], alpha])
+ });
+ let y_pos = (buffer.height() - minor_img.height()) / 2;
+ let x_slice = (buffer.width() - self.options.traitline_x_offset) / 6;
+ let x_pos = 2 * (minor.tier - 1) * x_slice
+ + (x_slice - minor_img.width()) / 2
+ + self.options.traitline_x_offset;
+ imageops::overlay(buffer, &minor_img, x_pos, y_pos);
+ Ok(())
+ }
+
+ fn render_major_trait(
+ &mut self,
+ buffer: &mut RgbaImage,
+ major: &Trait,
+ vertical_pos: u8,
+ chosen: bool,
+ ) -> Result<(), RenderError> {
+ let major_img = self.api.get_image(&major.icon)?.resize(
+ self.options.trait_size,
+ self.options.trait_size,
+ imageops::FilterType::CatmullRom,
+ );
+ let major_img = if !chosen {
+ major_img.grayscale()
+ } else {
+ major_img
+ };
+ let y_slice = buffer.height() / 3;
+ let y_pos = vertical_pos as u32 * y_slice + (y_slice - major_img.height()) / 2;
+ let x_slice = (buffer.width() - self.options.traitline_x_offset) / 6;
+ let x_pos = 2 * (major.tier - 1) * x_slice
+ + x_slice
+ + (x_slice - major_img.width()) / 2
+ + self.options.traitline_x_offset;
+ imageops::overlay(buffer, &major_img, x_pos, y_pos);
+ Ok(())
+ }
+
+ fn render_line(
+ &mut self,
+ buffer: &mut RgbaImage,
+ tier: u8,
+ choice: TraitChoice,
+ ) -> Result<(), RenderError> {
+ if choice == TraitChoice::None {
+ return Ok(());
+ }
+
+ let x_slice = (buffer.width() - self.options.traitline_x_offset) / 6;
+ let start_x = 2 * tier as u32 * x_slice
+ + (x_slice + self.options.trait_size) / 2
+ + self.options.traitline_x_offset;
+ let end_x = start_x + x_slice - self.options.trait_size;
+ let start_y = (buffer.height() - self.options.line_height) / 2;
+ let y_slice = buffer.height() / 3;
+ let end_y = y_slice * (choice as u32 - 1) + (y_slice - self.options.line_height) / 2;
+
+ drawing::draw_convex_polygon_mut(
+ buffer,
+ &[
+ Point::new(start_x as i32, start_y as i32),
+ Point::new(
+ start_x as i32,
+ start_y as i32 + self.options.line_height as i32,
+ ),
+ Point::new(end_x as i32, end_y as i32 + self.options.line_height as i32),
+ Point::new(end_x as i32, end_y as i32),
+ ],
+ self.options.line_color,
+ );
+ if tier == 2 {
+ return Ok(());
+ }
+
+ drawing::draw_convex_polygon_mut(
+ buffer,
+ &[
+ Point::new(
+ 2 * x_slice as i32 + start_x as i32 - self.options.trait_size as i32,
+ start_y as i32,
+ ),
+ Point::new(
+ 2 * x_slice as i32 + start_x as i32 - self.options.trait_size as i32,
+ start_y as i32 + self.options.line_height as i32,
+ ),
+ Point::new(
+ self.options.trait_size as i32 + end_x as i32,
+ end_y as i32 + self.options.line_height as i32,
+ ),
+ Point::new(self.options.trait_size as i32 + end_x as i32, end_y as i32),
+ ],
+ self.options.line_color,
+ );
+
+ Ok(())
+ }
+
+ pub fn render_traitline(&mut self, traitline: &Traitline) -> Result<RgbaImage, RenderError> {
+ let (spec, choices) = traitline;
+ let mut background = self.api.get_image(&spec.background)?;
+ let mut buffer = background
+ .crop(
+ 0,
+ background.height() - BG_CROP_HEIGHT,
+ BG_CROP_WIDTH,
+ BG_CROP_HEIGHT,
+ )
+ .brighten(self.options.traitline_brightness)
+ .resize(
+ self.options.traitline_width,
+ self.options.traitline_height,
+ imageops::FilterType::CatmullRom,
+ )
+ .to_rgba();
+
+ let minor_traits = self.api.get_traits(&spec.minor_traits)?;
+ for minor in &minor_traits {
+ self.render_minor_trait(&mut buffer, &minor)?;
+ }
+
+ let major_traits = self.api.get_traits(&spec.major_traits)?;
+ for (i, major) in major_traits.iter().enumerate() {
+ let choice = choices[major.tier as usize - 1];
+ let vert_pos = (i % 3) as u8;
+ let chosen = choice as u8 == vert_pos + 1;
+ self.render_major_trait(&mut buffer, &major, vert_pos, chosen)?;
+ }
+
+ for (tier, choice) in choices.iter().enumerate() {
+ self.render_line(&mut buffer, tier as u8, *choice)?;
+ }
+ Ok(buffer)
+ }
+
+ pub fn render_specialization_name(
+ &mut self,
+ specialization: &Specialization,
+ ) -> Result<RgbaImage, RenderError> {
+ let scale = self.options.text_size;
+ let mut buffer = ImageBuffer::new(self.options.traitline_width, scale);
+ drawing::draw_text_mut(
+ &mut buffer,
+ self.options.text_color,
+ 0,
+ 0,
+ Scale {
+ x: scale as f32,
+ y: scale as f32,
+ },
+ &self.options.font,
+ &specialization.name,
+ );
+ Ok(buffer)
+ }
+
+ pub fn render_buildtemplate(
+ &mut self,
+ build: &BuildTemplate,
+ ) -> Result<RgbaImage, RenderError> {
+ let images = self.construct_parts(build)?;
+ self.merge_parts(&images)
+ }
+
+ fn construct_parts(&mut self, build: &BuildTemplate) -> Result<Vec<RgbaImage>, RenderError> {
+ let mut images: Vec<RgbaImage> = Vec::new();
+
+ for traitline in build.traitlines().iter().filter(|x| x.is_some()) {
+ let traitline = traitline.as_ref().unwrap();
+ if self.options.render_specialization_names {
+ let header = self.render_specialization_name(&traitline.0)?;
+ images.push(header);
+ }
+ let inner = self.render_traitline(&traitline)?;
+ images.push(inner);
+ }
+
+ let needs_space = build.skill_count() > 0 && build.traitline_count() > 0;
+ if needs_space {
+ let separator = ImageBuffer::from_pixel(
+ self.options.traitline_width,
+ self.options.skill_offset,
+ self.options.background_color,
+ );
+ images.push(separator);
+ }
+
+ if build.skill_count() > 0 {
+ let skills = self.render_skills(build.skills())?;
+ images.push(skills);
+ }
+
+ Ok(images)
+ }
+
+ fn merge_parts(&mut self, images: &[RgbaImage]) -> Result<RgbaImage, RenderError> {
+ let width = images.iter().map(RgbaImage::width).max().unwrap();
+ let height = images.iter().map(RgbaImage::height).sum();
+ let mut buffer = ImageBuffer::from_pixel(width, height, self.options.background_color);
+ let mut pos_y = 0;
+
+ for image in images {
+ imageops::overlay(&mut buffer, image, 0, pos_y);
+ pos_y += image.height();
+ }
+
+ Ok(buffer)
+ }
+}