feat: Add smart density defaults based on material type (#2)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Auto-fill density when entering material type in filament forms: - Add materialDefaults.ts with standard densities for common materials (PLA, ABS, PETG, TPU, PC, etc.) - Material field now uses AutoComplete with suggestions - Density auto-fills on material select or blur (fuzzy matching) - New filaments default to 1.75mm diameter and 1.24 g/cm³ (PLA) - Applied to both full filament create page and quick create modal Reduces tedious data entry for common materials while still allowing custom values for specialty filaments. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -263,7 +263,8 @@
|
|||||||
"import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.<br><br>A Manufacturer object will be created automatically when you click Ok, if necessary.",
|
"import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.<br><br>A Manufacturer object will be created automatically when you click Ok, if necessary.",
|
||||||
"created_success": "Created filament \"{{name}}\"",
|
"created_success": "Created filament \"{{name}}\"",
|
||||||
"vendor_placeholder": "Select a manufacturer",
|
"vendor_placeholder": "Select a manufacturer",
|
||||||
"name_placeholder": "E.g. Matte Black"
|
"name_placeholder": "E.g. Matte Black",
|
||||||
|
"material_placeholder": "PLA, PETG, ABS, etc."
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"add_spool": "Add Spool"
|
"add_spool": "Add Spool"
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useSelect } from "@refinedev/antd";
|
import { useSelect } from "@refinedev/antd";
|
||||||
import { useInvalidate, useTranslate } from "@refinedev/core";
|
import { useInvalidate, useTranslate } from "@refinedev/core";
|
||||||
import { Button, ColorPicker, Divider, Form, Input, InputNumber, Modal, Select, message } from "antd";
|
import { AutoComplete, Button, ColorPicker, Divider, Form, Input, InputNumber, Modal, Select, message } from "antd";
|
||||||
import { PlusOutlined } from "@ant-design/icons";
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Color } from "antd/es/color-picker";
|
import { Color } from "antd/es/color-picker";
|
||||||
import { IFilament } from "../pages/filaments/model";
|
import { IFilament } from "../pages/filaments/model";
|
||||||
import { IVendor } from "../pages/vendors/model";
|
import { IVendor } from "../pages/vendors/model";
|
||||||
|
import {
|
||||||
|
DEFAULT_DENSITY,
|
||||||
|
DEFAULT_DIAMETER,
|
||||||
|
getDensityForMaterial,
|
||||||
|
KNOWN_MATERIALS,
|
||||||
|
} from "../utils/materialDefaults";
|
||||||
import { getAPIURL } from "../utils/url";
|
import { getAPIURL } from "../utils/url";
|
||||||
import { VendorCreateModal } from "./vendorCreateModal";
|
import { VendorCreateModal } from "./vendorCreateModal";
|
||||||
|
|
||||||
@@ -15,10 +21,6 @@ export interface FilamentCreateModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default values for common filament properties
|
|
||||||
const DEFAULT_DIAMETER = 1.75;
|
|
||||||
const DEFAULT_DENSITY = 1.24; // PLA
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modal for quickly creating a filament inline without navigating away from the current page.
|
* Modal for quickly creating a filament inline without navigating away from the current page.
|
||||||
* Includes essential fields and the ability to create a vendor inline.
|
* Includes essential fields and the ability to create a vendor inline.
|
||||||
@@ -29,6 +31,23 @@ export function FilamentCreateModal(props: FilamentCreateModalProps) {
|
|||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isVendorModalOpen, setIsVendorModalOpen] = useState(false);
|
const [isVendorModalOpen, setIsVendorModalOpen] = useState(false);
|
||||||
|
const [materialSearch, setMaterialSearch] = useState("");
|
||||||
|
|
||||||
|
// Material options for AutoComplete
|
||||||
|
const materialOptions = useMemo(() => {
|
||||||
|
const filtered = materialSearch
|
||||||
|
? KNOWN_MATERIALS.filter((m) => m.toLowerCase().includes(materialSearch.toLowerCase()))
|
||||||
|
: KNOWN_MATERIALS;
|
||||||
|
return filtered.map((material) => ({ value: material, label: material }));
|
||||||
|
}, [materialSearch]);
|
||||||
|
|
||||||
|
// Auto-fill density when material is selected or entered
|
||||||
|
const handleMaterialChange = (value: string) => {
|
||||||
|
const density = getDensityForMaterial(value);
|
||||||
|
if (density !== undefined) {
|
||||||
|
form.setFieldValue("density", density);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Vendor select
|
// Vendor select
|
||||||
const { selectProps: vendorSelectProps } = useSelect<IVendor>({
|
const { selectProps: vendorSelectProps } = useSelect<IVendor>({
|
||||||
@@ -171,7 +190,14 @@ export function FilamentCreateModal(props: FilamentCreateModalProps) {
|
|||||||
label={t("filament.fields.material")}
|
label={t("filament.fields.material")}
|
||||||
name="material"
|
name="material"
|
||||||
>
|
>
|
||||||
<Input maxLength={64} placeholder="PLA, PETG, ABS, etc." />
|
<AutoComplete
|
||||||
|
options={materialOptions}
|
||||||
|
onSearch={setMaterialSearch}
|
||||||
|
onSelect={handleMaterialChange}
|
||||||
|
onBlur={(e) => handleMaterialChange((e.target as HTMLInputElement).value)}
|
||||||
|
placeholder={t("filament.form.material_placeholder")}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import { Create, useForm, useSelect } from "@refinedev/antd";
|
import { Create, useForm, useSelect } from "@refinedev/antd";
|
||||||
import { HttpError, IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core";
|
import { HttpError, IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core";
|
||||||
import { Button, ColorPicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
|
import { AutoComplete, Button, ColorPicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
|
||||||
import { PlusOutlined } from "@ant-design/icons";
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
import TextArea from "antd/es/input/TextArea";
|
import TextArea from "antd/es/input/TextArea";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||||
import { FilamentImportModal } from "../../components/filamentImportModal";
|
import { FilamentImportModal } from "../../components/filamentImportModal";
|
||||||
import { VendorCreateModal } from "../../components/vendorCreateModal";
|
import { VendorCreateModal } from "../../components/vendorCreateModal";
|
||||||
import { MultiColorPicker } from "../../components/multiColorPicker";
|
import { MultiColorPicker } from "../../components/multiColorPicker";
|
||||||
|
import {
|
||||||
|
DEFAULT_DENSITY,
|
||||||
|
DEFAULT_DIAMETER,
|
||||||
|
getDensityForMaterial,
|
||||||
|
KNOWN_MATERIALS,
|
||||||
|
} from "../../utils/materialDefaults";
|
||||||
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
||||||
import { ExternalFilament } from "../../utils/queryExternalDB";
|
import { ExternalFilament } from "../../utils/queryExternalDB";
|
||||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||||
@@ -36,6 +42,15 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
|||||||
const [isVendorModalOpen, setIsVendorModalOpen] = useState(false);
|
const [isVendorModalOpen, setIsVendorModalOpen] = useState(false);
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
const [colorType, setColorType] = useState<"single" | "multi">("single");
|
const [colorType, setColorType] = useState<"single" | "multi">("single");
|
||||||
|
const [materialSearch, setMaterialSearch] = useState("");
|
||||||
|
|
||||||
|
// Material options for AutoComplete
|
||||||
|
const materialOptions = useMemo(() => {
|
||||||
|
const filtered = materialSearch
|
||||||
|
? KNOWN_MATERIALS.filter((m) => m.toLowerCase().includes(materialSearch.toLowerCase()))
|
||||||
|
: KNOWN_MATERIALS;
|
||||||
|
return filtered.map((material) => ({ value: material, label: material }));
|
||||||
|
}, [materialSearch]);
|
||||||
|
|
||||||
const { form, formProps, formLoading, onFinish, redirect } = useForm<
|
const { form, formProps, formLoading, onFinish, redirect } = useForm<
|
||||||
IFilament,
|
IFilament,
|
||||||
@@ -56,6 +71,10 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
|||||||
|
|
||||||
// Parse the extra fields from string values into real types
|
// Parse the extra fields from string values into real types
|
||||||
formProps.initialValues = ParsedExtras(formProps.initialValues);
|
formProps.initialValues = ParsedExtras(formProps.initialValues);
|
||||||
|
} else {
|
||||||
|
// Set defaults for new filaments
|
||||||
|
formProps.initialValues.density = formProps.initialValues.density ?? DEFAULT_DENSITY;
|
||||||
|
formProps.initialValues.diameter = formProps.initialValues.diameter ?? DEFAULT_DIAMETER;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (redirectTo: "list" | "create") => {
|
const handleSubmit = async (redirectTo: "list" | "create") => {
|
||||||
@@ -79,6 +98,14 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
|||||||
setIsVendorModalOpen(false);
|
setIsVendorModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-fill density when material is selected or entered
|
||||||
|
const handleMaterialChange = (value: string) => {
|
||||||
|
const density = getDensityForMaterial(value);
|
||||||
|
if (density !== undefined) {
|
||||||
|
form.setFieldValue("density", density);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const importFilament = async (filament: ExternalFilament) => {
|
const importFilament = async (filament: ExternalFilament) => {
|
||||||
const vendor = await getOrCreateVendorFromExternal(filament.manufacturer);
|
const vendor = await getOrCreateVendorFromExternal(filament.manufacturer);
|
||||||
await invalidate({
|
await invalidate({
|
||||||
@@ -265,7 +292,14 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input maxLength={64} />
|
<AutoComplete
|
||||||
|
options={materialOptions}
|
||||||
|
onSearch={setMaterialSearch}
|
||||||
|
onSelect={handleMaterialChange}
|
||||||
|
onBlur={(e) => handleMaterialChange((e.target as HTMLInputElement).value)}
|
||||||
|
placeholder={t("filament.form.material_placeholder")}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("filament.fields.price")}
|
label={t("filament.fields.price")}
|
||||||
|
|||||||
90
client/src/utils/materialDefaults.ts
Normal file
90
client/src/utils/materialDefaults.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Standard material densities for common 3D printing filaments.
|
||||||
|
* Values are in g/cm³.
|
||||||
|
*
|
||||||
|
* Reference: https://www.simplify3d.com/resources/materials-guide/
|
||||||
|
*/
|
||||||
|
export const MATERIAL_DENSITY_MAP: Record<string, number> = {
|
||||||
|
PLA: 1.24,
|
||||||
|
ABS: 1.04,
|
||||||
|
PETG: 1.27,
|
||||||
|
NYLON: 1.52,
|
||||||
|
TPU: 1.21,
|
||||||
|
PC: 1.3,
|
||||||
|
WOOD: 1.28,
|
||||||
|
CF: 1.3,
|
||||||
|
"PC/ABS": 1.19,
|
||||||
|
HIPS: 1.03,
|
||||||
|
PVA: 1.23,
|
||||||
|
ASA: 1.05,
|
||||||
|
PP: 0.9,
|
||||||
|
POM: 1.4,
|
||||||
|
PMMA: 1.18,
|
||||||
|
FPE: 2.16,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default filament diameter in mm (most common standard).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_DIAMETER = 1.75;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default density in g/cm³ (PLA, most common material).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_DENSITY = 1.24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of known materials for AutoComplete suggestions.
|
||||||
|
*/
|
||||||
|
export const KNOWN_MATERIALS = Object.keys(MATERIAL_DENSITY_MAP);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the density for a material type using fuzzy matching.
|
||||||
|
* Handles variations like "PLA Silk", "PETG-CF", "Wood PLA", etc.
|
||||||
|
*
|
||||||
|
* @param material - The material string to look up
|
||||||
|
* @returns The density if a match is found, undefined otherwise
|
||||||
|
*/
|
||||||
|
export function getDensityForMaterial(material: string | undefined): number | undefined {
|
||||||
|
if (!material) return undefined;
|
||||||
|
|
||||||
|
const normalizedInput = material.toUpperCase().trim();
|
||||||
|
|
||||||
|
// Exact match first
|
||||||
|
if (MATERIAL_DENSITY_MAP[normalizedInput]) {
|
||||||
|
return MATERIAL_DENSITY_MAP[normalizedInput];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy match: check if input starts with or contains a known material
|
||||||
|
// Sort by length descending to match longer patterns first (e.g., "PC/ABS" before "PC")
|
||||||
|
const sortedMaterials = KNOWN_MATERIALS.sort((a, b) => b.length - a.length);
|
||||||
|
|
||||||
|
for (const knownMaterial of sortedMaterials) {
|
||||||
|
// Check if the input starts with the material (e.g., "PLA Silk" starts with "PLA")
|
||||||
|
if (normalizedInput.startsWith(knownMaterial)) {
|
||||||
|
return MATERIAL_DENSITY_MAP[knownMaterial];
|
||||||
|
}
|
||||||
|
// Check if the input contains the material as a word boundary
|
||||||
|
// This handles cases like "Wood PLA" or "CF-PETG"
|
||||||
|
const regex = new RegExp(`\\b${knownMaterial}\\b`, "i");
|
||||||
|
if (regex.test(normalizedInput)) {
|
||||||
|
return MATERIAL_DENSITY_MAP[knownMaterial];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter materials for AutoComplete based on input.
|
||||||
|
* Returns materials that contain the search string (case-insensitive).
|
||||||
|
*
|
||||||
|
* @param searchValue - The search string
|
||||||
|
* @returns Filtered list of material suggestions
|
||||||
|
*/
|
||||||
|
export function filterMaterials(searchValue: string): string[] {
|
||||||
|
if (!searchValue) return KNOWN_MATERIALS;
|
||||||
|
|
||||||
|
const normalizedSearch = searchValue.toUpperCase().trim();
|
||||||
|
return KNOWN_MATERIALS.filter((material) => material.includes(normalizedSearch));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user