This commit is contained in:
Lucas Dower 2023-06-28 22:09:54 +01:00
commit 334f5e07f4
No known key found for this signature in database
GPG Key ID: B3EE6B8499593605
15 changed files with 302 additions and 82 deletions

View File

@ -39,6 +39,7 @@ export const en_GB = {
unsupported_image_type: 'Cannot read \'{{file_name}}\', unsupported file type \'{{file_type}}\'',
components: {
input: '3D Model (.obj, .glb)',
no_file_chosen: 'No file chosen',
rotation: 'Rotation',
},
},
@ -162,5 +163,24 @@ export const en_GB = {
off: 'Off',
advanced_settings: 'Advanced settings'
},
toolbar: {
view_mesh: 'View mesh',
view_voxel_mesh: 'View voxel mesh',
view_block_mesh: 'View block mesh',
toggle_grid: 'Toggle grid',
toggle_axes: 'Toggle axes',
toggle_night_vision: 'Toggle night vision',
toggle_slice_viewer: 'Toggle slice viewer',
decrement_slice: 'Decrement slice',
increment_slice: 'Increment slice',
zoom_in: 'Zoom in',
zoom_out: 'Zoom out',
reset_camera: 'Reset camera',
perspective_camera: 'Perspective camera',
orthographic_camera: 'Orthographic camera',
open_github_repo: 'Open GitHub repo',
open_github_issues: 'Open GitHub issues',
join_discord: 'Join Discord server',
}
},
};

13
package-lock.json generated
View File

@ -34,6 +34,7 @@
"eslint-config-google": "^0.14.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"file-loader": "^6.2.0",
"ga-gtag": "^1.1.7",
"hsv-rgb": "^1.0.0",
"html-webpack-plugin": "^5.5.0",
"i18next": "^22.4.14",
@ -4925,6 +4926,12 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
},
"node_modules/ga-gtag": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/ga-gtag/-/ga-gtag-1.1.7.tgz",
"integrity": "sha512-fT/D87hhuNIAmEB2z9mxz88gMFYc1olpX/fETHidZ51sYJ4y6OFch8HZ0DoNWPGFw1BCHB0fqt68dUWvd8kM1Q==",
"dev": true
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@ -14573,6 +14580,12 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
},
"ga-gtag": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/ga-gtag/-/ga-gtag-1.1.7.tgz",
"integrity": "sha512-fT/D87hhuNIAmEB2z9mxz88gMFYc1olpX/fETHidZ51sYJ4y6OFch8HZ0DoNWPGFw1BCHB0fqt68dUWvd8kM1Q==",
"dev": true
},
"gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",

View File

@ -49,6 +49,7 @@
"eslint-config-google": "^0.14.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"file-loader": "^6.2.0",
"ga-gtag": "^1.1.7",
"hsv-rgb": "^1.0.0",
"html-webpack-plugin": "^5.5.0",
"i18next": "^22.4.14",

34
src/analytics.ts Normal file
View File

@ -0,0 +1,34 @@
import { AppConfig } from './config';
import { AppConsole } from './ui/console';
const gtag = require('ga-gtag');
export class AppAnalytics {
private _ready: boolean;
private static _instance: AppAnalytics;
public static get Get() {
return this._instance || (this._instance = new this());
}
private constructor() {
this._ready = false;
}
public static Init() {
gtag.install('G-W0SCWQ7HGJ', { 'send_page_view': true });
gtag.gtag('js', new Date());
gtag.gtag('config', 'G-W0SCWQ7HGJ', AppConfig.Get.VERSION_TYPE === 'd' ? { 'debug_mode': true } : {});
this.Get._ready = true;
this.Event('init', {
version: AppConfig.Get.getVersionString(),
})
}
public static Event(id: string, attributes?: any) {
if (this.Get._ready) {
console.log('[Analytics]: Tracked event', id, attributes);
gtag.gtag('event', id, Object.assign(attributes ?? {}, AppConfig.Get.VERSION_TYPE === 'd' ? { 'debug_mode': true } : {}));
}
}
}

View File

@ -1,4 +1,5 @@
import '../styles.css';
import { AppAnalytics } from './analytics';
import { FallableBehaviour } from './block_mesh';
import { ArcballCamera } from './camera';
@ -38,6 +39,8 @@ export class AppContext {
}
public static async init() {
AppAnalytics.Init();
await Localiser.Get.init();
AppConsole.info(LOC('init.initialising'));
@ -81,11 +84,13 @@ export class AppContext {
private async _import(): Promise<boolean> {
// Gather data from the UI to send to the worker
const components = UI.Get.layout.import.components;
let filetype: string;
AppConsole.info(LOC('import.importing_mesh'));
{
// Instruct the worker to perform the job and await the result
const file = components.input.getValue();
filetype = file.type;
const resultImport = await this._workerController.execute({
action: 'Import',
@ -131,6 +136,9 @@ export class AppContext {
}
AppConsole.success(LOC('import.rendered_mesh'));
AppAnalytics.Event('import', {
'filetype': filetype,
});
return true;
}
@ -163,6 +171,7 @@ export class AppContext {
}
AppConsole.success(LOC('materials.updated_materials'));
AppAnalytics.Event('materials')
return true;
}
@ -222,6 +231,14 @@ export class AppContext {
}
AppConsole.success(LOC('voxelise.rendered_voxel_mesh'));
AppAnalytics.Event('voxelise', {
constraintAxis: components.constraintAxis.getValue(),
voxeliser: components.voxeliser.getValue(),
size: components.size.getValue(),
useMultisampleColouring: components.multisampleColouring.getValue(),
enableAmbientOcclusion: components.ambientOcclusion.getValue(),
voxelOverlapRule: components.voxelOverlapRule.getValue(),
});
return true;
}
@ -287,6 +304,16 @@ export class AppContext {
}
AppConsole.success(LOC('assign.rendered_block_mesh'));
AppAnalytics.Event('assign', {
dithering: components.dithering.getValue(),
ditheringMagnitude: components.ditheringMagnitude.getValue(),
fallable: components.fallable.getValue() as FallableBehaviour,
resolution: Math.pow(2, components.colourAccuracy.getValue()),
calculateLighting: components.calculateLighting.getValue(),
lightThreshold: components.lightThreshold.getValue(),
contextualAveraging: components.contextualAveraging.getValue(),
errorWeight: components.errorWeight.getValue() / 10,
});
return true;
}
@ -327,6 +354,9 @@ export class AppContext {
}
AppConsole.success(LOC('export.exported_structure'));
AppAnalytics.Event('export', {
exporter: components.export.getValue(),
});
return true;
}

View File

@ -8,10 +8,10 @@ export class AppConfig {
return this._instance || (this._instance = new this());
}
public readonly RELEASE_MODE = true;
public readonly RELEASE_MODE;
public readonly MAJOR_VERSION = 0;
public readonly MINOR_VERSION = 8;
public readonly HOTFIX_VERSION = 5;
public readonly HOTFIX_VERSION = 8;
public readonly VERSION_TYPE: 'd' | 'a' | 'r' = 'r'; // dev, alpha, or release build
public readonly MINECRAFT_VERSION = '1.19.4';
@ -43,9 +43,14 @@ export class AppConfig {
public readonly FRESNEL_MIX = 0.3;
private constructor() {
this.RELEASE_MODE = this.VERSION_TYPE === 'r';
}
public dumpConfig() {
LOG(this);
}
public getVersionString() {
return `v${this.MAJOR_VERSION}.${this.MINOR_VERSION}.${this.HOTFIX_VERSION}${this.VERSION_TYPE}`;
}
}

View File

@ -23,6 +23,8 @@ export type Concat<K extends string, P extends string> =
export type TLocalisedString = TBrand<string, 'loc'>;
export type TLocalisedKey = DeepLeafKeys<TTranslationMap>;
export class Localiser {
/* Singleton */
private static _instance: Localiser;

View File

@ -152,7 +152,7 @@ export class CheckboxComponent extends ConfigComponent<boolean, HTMLSelectElemen
if (this.enabled) {
if (this.hovered) {
checkboxTextElement.classList.add('text-light');
} else if (this.getValue()) {
} else {
checkboxTextElement.classList.add('text-standard');
}
} else {

View File

@ -3,20 +3,22 @@ import * as path from 'path';
import { ASSERT } from '../../util/error_util';
import { UIUtil } from '../../util/ui_util';
import { ConfigComponent } from './config';
import { AppIcons } from '../icons';
import { LOC } from '../../localiser';
export class FileComponent extends ConfigComponent<File, HTMLDivElement> {
private _loadedFilePath: string;
private _loadedFilePath: string | null;
public constructor() {
super();
this._loadedFilePath = '';
this._loadedFilePath = null;
}
protected override _generateInnerHTML() {
return `
<div class="input-file struct-prop" id="${this._getId()}">
<input type="file" accept=".obj,,.glb" style="display: none;" id="${this._getId()}-input">
${this._loadedFilePath}
${this._loadedFilePath ?? LOC('import.components.no_file_chosen')}
</div>
`;
}
@ -62,8 +64,12 @@ export class FileComponent extends ConfigComponent<File, HTMLDivElement> {
}
protected override _updateStyles() {
const parsedPath = path.parse(this._loadedFilePath);
this._getElement().innerHTML = parsedPath.name + parsedPath.ext;
if (this._loadedFilePath) {
const parsedPath = path.parse(this._loadedFilePath);
this._getElement().innerHTML = parsedPath.name + parsedPath.ext;
} else {
this._getElement().innerHTML = `<i>${LOC('import.components.no_file_chosen')}</i>`;
}
UIUtil.updateStyles(this._getElement(), {
isHovered: this.hovered,
@ -71,4 +77,8 @@ export class FileComponent extends ConfigComponent<File, HTMLDivElement> {
isActive: false,
});
}
public override refresh(): void {
this._getElement().innerHTML = `<i>${LOC('import.components.no_file_chosen')}</i>`;
}
}

View File

@ -22,17 +22,20 @@ export class HeaderComponent extends BaseComponent<HTMLDivElement> {
this._githubButton = new ToolbarItemComponent({ id: 'gh', iconSVG: AppIcons.GITHUB })
.onClick(() => {
window.open('https://github.com/LucasDower/ObjToSchematic');
});
})
.setTooltip('toolbar.open_github_repo');
this._bugButton = new ToolbarItemComponent({ id: 'bug', iconSVG: AppIcons.BUG })
.onClick(() => {
window.open('https://github.com/LucasDower/ObjToSchematic/issues');
});
})
.setTooltip('toolbar.open_github_issues');
this._discordButton = new ToolbarItemComponent({ id: 'disc', iconSVG: AppIcons.DISCORD })
.onClick(() => {
window.open('https://discord.gg/McS2VrBZPD');
});
})
.setTooltip('toolbar.join_discord');
}
// Header element shouldn't be
@ -53,7 +56,7 @@ export class HeaderComponent extends BaseComponent<HTMLDivElement> {
ObjToSchematic
</div>
<div class="row-item subtitle">
v${AppConfig.Get.MAJOR_VERSION}.${AppConfig.Get.MINOR_VERSION}.${AppConfig.Get.HOTFIX_VERSION}${AppConfig.Get.VERSION_TYPE} Minecraft ${AppConfig.Get.MINECRAFT_VERSION}
${AppConfig.Get.getVersionString()} Minecraft ${AppConfig.Get.MINECRAFT_VERSION}
</div>
</div>
</div>
@ -64,16 +67,23 @@ export class HeaderComponent extends BaseComponent<HTMLDivElement> {
${this._discordButton.generateHTML()}
</div>
</div>
<div class="col-container header-cols">
<div class="col-container" style="padding-top: 5px;" id="header-desc">
<div class="row-container header-cols">
<div class="row-container" style="padding-top: 5px;" id="header-desc">
${LOC('description')}
</div>
<div class="row-container privacy-disclaimer" style="padding-top: 5px;">
This site may use cookies and similar tracking technologies (like web beacons) to access and store information about usage.
</div>
</div>
`;
}
public refresh() {
UIUtil.getElementById('header-desc').innerText = LOC('description');
this._githubButton.updateTranslation();
this._bugButton.updateTranslation();
this._discordButton.updateTranslation();
}
public override registerEvents(): void {

View File

@ -1,6 +1,7 @@
import { ASSERT } from '../../util/error_util';
import { UIUtil } from '../../util/ui_util';
import { BaseComponent } from './base';
import { LOC, TLocalisedKey } from '../../localiser';
export type TToolbarBooleanProperty = 'enabled' | 'active';
@ -15,6 +16,7 @@ export class ToolbarItemComponent extends BaseComponent<HTMLDivElement> {
private _onClick?: () => void;
private _isActive: boolean;
private _grow: boolean;
private _tooltipLocKey: TLocalisedKey | null;
public constructor(params: TToolbarItemParams) {
super();
@ -33,6 +35,7 @@ export class ToolbarItemComponent extends BaseComponent<HTMLDivElement> {
}
this._label = '';
this._tooltipLocKey = null;
}
public setGrow() {
@ -40,6 +43,12 @@ export class ToolbarItemComponent extends BaseComponent<HTMLDivElement> {
return this;
}
public updateTranslation() {
if (this._tooltipLocKey) {
UIUtil.getElementById(this._getId() + '-tooltip').innerHTML = LOC(this._tooltipLocKey);
}
}
public setActive(isActive: boolean) {
this._isActive = isActive;
this._updateStyles();
@ -90,6 +99,11 @@ export class ToolbarItemComponent extends BaseComponent<HTMLDivElement> {
return this;
}
public setTooltip(text: TLocalisedKey) {
this._tooltipLocKey = text;
return this;
}
public generateHTML() {
if (this._grow) {
return `
@ -98,11 +112,20 @@ export class ToolbarItemComponent extends BaseComponent<HTMLDivElement> {
</div>
`;
} else {
return `
<div class="struct-prop container-icon-button" style="aspect-ratio: 1;" id="${this._getId()}">
if (this._tooltipLocKey === null) {
return `
<div class="struct-prop container-icon-button " style="aspect-ratio: 1;" id="${this._getId()}">
${this._iconSVG.outerHTML} ${this._label}
</div>
`;
} else {
return `
<div class="struct-prop container-icon-button hover-text" style="aspect-ratio: 1;" id="${this._getId()}">
${this._iconSVG.outerHTML} ${this._label}
<span class="tooltip-text left" id="${this._getId()}-tooltip">${LOC(this._tooltipLocKey)}</span>
</div>
`;
}
}
}

View File

@ -378,7 +378,8 @@ export class UI {
})
.isEnabled(() => {
return Renderer.Get.getModelsAvailable() >= MeshType.TriangleMesh;
}),
})
.setTooltip('toolbar.view_mesh'),
'voxelMesh': new ToolbarItemComponent({ id: 'voxelMesh', iconSVG: AppIcons.VOXEL })
.onClick(() => {
Renderer.Get.setModelToUse(MeshType.VoxelMesh);
@ -388,7 +389,8 @@ export class UI {
})
.isEnabled(() => {
return Renderer.Get.getModelsAvailable() >= MeshType.VoxelMesh;
}),
})
.setTooltip('toolbar.view_voxel_mesh'),
'blockMesh': new ToolbarItemComponent({ id: 'blockMesh', iconSVG: AppIcons.BLOCK })
.onClick(() => {
Renderer.Get.setModelToUse(MeshType.BlockMesh);
@ -398,7 +400,8 @@ export class UI {
})
.isEnabled(() => {
return Renderer.Get.getModelsAvailable() >= MeshType.BlockMesh;
}),
})
.setTooltip('toolbar.view_block_mesh'),
},
componentOrder: ['mesh', 'voxelMesh', 'blockMesh'],
},
@ -413,14 +416,16 @@ export class UI {
})
.isEnabled(() => {
return Renderer.Get.getActiveMeshType() !== MeshType.None;
}),
})
.setTooltip('toolbar.toggle_grid'),
'axes': new ToolbarItemComponent({ id: 'axes', iconSVG: AppIcons.AXES })
.onClick(() => {
Renderer.Get.toggleIsAxesEnabled();
})
.isActive(() => {
return Renderer.Get.isAxesEnabled();
}),
})
.setTooltip('toolbar.toggle_axes'),
'night-vision': new ToolbarItemComponent({ id: 'night', iconSVG: AppIcons.BULB })
.onClick(() => {
Renderer.Get.toggleIsNightVisionEnabled();
@ -430,7 +435,8 @@ export class UI {
})
.isEnabled(() => {
return Renderer.Get.canToggleNightVision();
}),
})
.setTooltip('toolbar.toggle_night_vision'),
},
componentOrder: ['grid', 'axes', 'night-vision'],
},
@ -445,7 +451,8 @@ export class UI {
})
.isActive(() => {
return Renderer.Get.isSliceViewerEnabled();
}),
})
.setTooltip('toolbar.toggle_slice_viewer'),
'plus': new ToolbarItemComponent({ id: 'plus', iconSVG: AppIcons.PLUS })
.onClick(() => {
Renderer.Get.incrementSliceHeight();
@ -453,7 +460,8 @@ export class UI {
.isEnabled(() => {
return Renderer.Get.isSliceViewerEnabled() &&
Renderer.Get.canIncrementSliceHeight();
}),
})
.setTooltip('toolbar.decrement_slice'),
'minus': new ToolbarItemComponent({ id: 'minus', iconSVG: AppIcons.MINUS })
.onClick(() => {
Renderer.Get.decrementSliceHeight();
@ -461,7 +469,8 @@ export class UI {
.isEnabled(() => {
return Renderer.Get.isSliceViewerEnabled() &&
Renderer.Get.canDecrementSliceHeight();
}),
})
.setTooltip('toolbar.increment_slice'),
},
componentOrder: ['slice', 'plus', 'minus'],
},
@ -476,15 +485,18 @@ export class UI {
'zoomOut': new ToolbarItemComponent({ id: 'zout', iconSVG: AppIcons.MINUS })
.onClick(() => {
ArcballCamera.Get.onZoomOut();
}),
})
.setTooltip('toolbar.zoom_out'),
'zoomIn': new ToolbarItemComponent({ id: 'zin', iconSVG: AppIcons.PLUS })
.onClick(() => {
ArcballCamera.Get.onZoomIn();
}),
})
.setTooltip('toolbar.zoom_in'),
'reset': new ToolbarItemComponent({ id: 'reset', iconSVG: AppIcons.CENTRE })
.onClick(() => {
ArcballCamera.Get.reset();
}),
})
.setTooltip('toolbar.reset_camera'),
},
componentOrder: ['zoomOut', 'zoomIn', 'reset'],
},
@ -496,14 +508,16 @@ export class UI {
})
.isActive(() => {
return ArcballCamera.Get.isPerspective();
}),
})
.setTooltip('toolbar.perspective_camera'),
'orthographic': new ToolbarItemComponent({ id: 'orth', iconSVG: AppIcons.ORTHOGRAPHIC })
.onClick(() => {
ArcballCamera.Get.setCameraMode('orthographic');
})
.isActive(() => {
return ArcballCamera.Get.isOrthographic();
}),
})
.setTooltip('toolbar.orthographic_camera'),
},
componentOrder: ['perspective', 'orthographic'],
},
@ -665,6 +679,20 @@ export class UI {
private _handleLanguageChange() {
HeaderComponent.Get.refresh();
Object.values(this._toolbarLeft.groups).forEach((group) => {
Object.values(group.components).forEach((comp) => {
comp.updateTranslation();
});
});
Object.values(this._toolbarRight.groups).forEach((group) => {
Object.values(group.components).forEach((comp) => {
comp.updateTranslation();
});
});
for (let i = 0; i < EAction.MAX; ++i) {
const group = this._getGroup(i);
const header = UIUtil.getElementById(`component_header_${group.id}`);
@ -776,10 +804,12 @@ export class UI {
* Enable a specific action.
*/
public enable(action: EAction) {
this._forEachComponent(action, (component) => {
component.setEnabled(true);
});
this._getGroup(action).execButton?.setEnabled(true);
if (action < EAction.MAX) {
this._forEachComponent(action, (component) => {
component.setEnabled(true);
});
this._getGroup(action).execButton?.setEnabled(true);
}
}
/**

View File

@ -14,31 +14,24 @@ export namespace UIUtil {
}
export function clearStyles(element: HTMLElement) {
element.classList.remove('style-inactive-disabled');
element.classList.remove('style-inactive-enabled');
element.classList.remove('style-inactive-hover');
element.classList.remove('style-active-disabled');
element.classList.remove('style-active-enabled');
element.classList.remove('style-active-hover');
element.classList.remove('disabled');
element.classList.remove('hover');
element.classList.remove('active');
}
export function updateStyles(element: HTMLElement, style: TStyleParams) {
clearStyles(element);
let styleToApply = `style`;
styleToApply += style.isActive ? '-active' : '-inactive';
if (style.isEnabled) {
if (style.isHovered) {
styleToApply += '-hover';
} else {
styleToApply += '-enabled';
}
} else {
styleToApply += '-disabled';
if (style.isActive) {
element.classList.add('active');
}
element.classList.add(styleToApply);
if (!style.isEnabled) {
element.classList.add('disabled');
}
if (style.isHovered && style.isEnabled) {
element.classList.add('hover');
}
}
}

View File

@ -253,6 +253,9 @@ select {
transition: width 0.2s;
}
.struct-prop {
display: flex;
align-items: center;
@ -264,42 +267,49 @@ select {
border-style: solid;
}
.style-inactive-disabled {
.struct-prop.disabled {
border-color: var(--gray-500);
color: var(--text-dark);
background: var(--gray-400);
cursor: inherit;
}
.style-inactive-enabled {
border-color: var(--gray-600);
color: var(--text-standard);
background: var(--gray-500);
}
.style-inactive-hover {
.struct-prop.hover {
border-color: var(--gray-700);
color: var(--text-light);
background: var(--gray-600);
cursor: pointer;
}
.style-active-disabled {
.struct-prop:not(.disabled):not(.hover) {
border-color: var(--gray-600);
color: var(--text-standard);
background: var(--gray-500);
}
.struct-prop.active.disabled {
border-color: var(--blue-450);
color: var(--text-dim);
background: var(--blue-400);
cursor: inherit;
}
.style-active-enabled {
border-color: var(--blue-600);
color: var(--text-bright);
background: var(--blue-500);
}
.style-active-hover {
.struct-prop.active.hover {
border-color: var(--blue-700);
color: var(--text-bright);
background: var(--blue-600);
cursor: pointer;
}
.struct-prop.active:not(.disabled):not(.hover) {
border-color: var(--blue-600);
color: var(--text-bright);
background: var(--blue-500);
}
.h-div {
height: 0px;
border-radius: 2px;
@ -348,7 +358,7 @@ select {
justify-content: center;
flex-grow: 1;
}
.spinbox-value.style-inactive-hover {
.spinbox-value .inactive .hover {
cursor: e-resize;
}
@ -676,4 +686,57 @@ a {
.hide {
display: none;
}
.tooltip-text {
visibility: hidden;
opacity: 0;
position: absolute;
z-index: 1;
font-size: var(--font-size-small);
color: var(--text-light);
background-color: var(--gray-600);
padding: 5px 10px;
border-radius: 5px;
border: 1px solid var(--gray-700);
transition: 0.15s;
pointer-events: none;
box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 16px;
white-space: nowrap;
}
.hover-text:hover:not(.disabled) .tooltip-text {
visibility: visible;
opacity: 1;
}
.top {
top: -40px;
left: -50%;
}
.bottom {
top: 25px;
left: -50%;
}
.left {
top: 1px;
right: 120%;
}
.right {
top: 2px;
left: 120%;
}
.hover-text {
position: relative;
}
.privacy-disclaimer {
font-size: var(--font-size-small);
color: var(--text-dim);
}

View File

@ -1,20 +1,6 @@
<!DOCTYPE html>
<html>
<head>
<!-- Google tag (gtag.js) -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-W0SCWQ7HGJ"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-W0SCWQ7HGJ");
</script>
<meta charset="utf8" />
<title>ObjToSchematic Web</title>
<meta