From c81c0879ddad6e1dbd79afdcc295ce41f08e093f Mon Sep 17 00:00:00 2001 From: Nazeeh Abdul-Hadi <196516325+nazeeh111@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:39:34 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(app):=20add=20editable=20mecha?= =?UTF-8?q?nical=20plate=20and=20bracket=20builder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +- docs/mechanical-parts.md | 35 ++ docs/solidframe-verification.md | 10 + .../app/src/bodys/fabricationParameters.ts | 178 ++++++++++ packages/app/src/bodys/fabricationPart.ts | 151 ++++++++ packages/app/src/commands/fabrication.ts | 71 ++++ .../src/commands/fabricationDialog.module.css | 207 +++++++++++ .../app/src/commands/fabricationDialog.ts | 328 ++++++++++++++++++ packages/app/src/commands/index.ts | 1 + .../app/test/bodys/fabricationPart.test.ts | 232 +++++++++++++ .../app/test/commands/fabrication.test.ts | 154 ++++++++ .../test/commands/fabricationDialog.test.ts | 141 ++++++++ packages/builder/src/ribbon.ts | 4 + packages/core/src/i18n/keys.ts | 34 ++ packages/i18n/src/en.ts | 37 ++ packages/i18n/src/pt-br.ts | 37 ++ packages/i18n/src/ru.ts | 38 ++ packages/i18n/src/zh-cn.ts | 35 ++ packages/ui/src/home/home.ts | 5 +- 19 files changed, 1706 insertions(+), 3 deletions(-) create mode 100644 docs/mechanical-parts.md create mode 100644 packages/app/src/bodys/fabricationParameters.ts create mode 100644 packages/app/src/bodys/fabricationPart.ts create mode 100644 packages/app/src/commands/fabrication.ts create mode 100644 packages/app/src/commands/fabricationDialog.module.css create mode 100644 packages/app/src/commands/fabricationDialog.ts create mode 100644 packages/app/test/bodys/fabricationPart.test.ts create mode 100644 packages/app/test/commands/fabrication.test.ts create mode 100644 packages/app/test/commands/fabricationDialog.test.ts diff --git a/README.md b/README.md index 3b488c7..afcdbf1 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,18 @@ Browser CAD with editable solids, parametric sketches, and local project files. -SolidFrame is a CAD application developed and maintained by **Nazeeh Abdul-Hadi**. It brings solid modeling, parametric sketches, feature history, geometry exchange, and local document storage into a focused browser workspace. Nazeeh’s work includes the project hub, editable workholding fixture, workspace styling, keyboard-accessible modeling controls, startup safeguards, and deployment improvements. +SolidFrame is a CAD application developed and maintained by **Nazeeh Abdul-Hadi**. It brings solid modeling, parametric sketches, feature history, geometry exchange, and local document storage into a focused browser workspace. Nazeeh’s work includes an editable mechanical part builder, the project hub and workholding fixture, workspace styling, keyboard-accessible modeling controls, startup safeguards, and deployment improvements. [Open SolidFrame](https://nazeeh111.github.io/SolidFrame/) · [Source and license](LICENSE) +## Design a drilled part + +The mechanical part builder creates a rectangular mounting plate or a sharp right-angle bracket from dimensions in millimetres. Set the stock dimensions and centered round through-hole grids; brackets have separate horizontal and vertical flange layouts. The editor checks the complete configuration before rebuilding the native solid, including hole spacing, edge clearance and the inside corner of a bracket. + +Edit the saved part through the dedicated part editor. Apply commits the configuration together, and undo/redo restores parameter changes. Invalid or cancelled edits keep the previous valid part. Native `.cd` documents retain the dimensions and hole layouts; STEP and STL exports contain geometry rather than the editable builder settings. + +This workflow models ideal geometry. It does not calculate sheet-metal bend allowance, thread or fastener standards, structural strength, or fabrication approval. See [mechanical parts](docs/mechanical-parts.md) for the controls, supported ranges and verification. + ## Start with real geometry Choose **Open editable sample** on the home screen. The workholding fixture contains seven native, editable solids: a base plate, two jaws, a workpiece, two locator pins, and a reference boss. It is an illustrative CAD exercise, not a fabrication-approved fixture. @@ -47,6 +55,7 @@ The production site is in `dist/`. Serve it through HTTP, rather than opening it ## Project development +- Original drilled-plate and right-angle-bracket builder with coupled hole-layout validation, atomic parameter editing and native-document persistence. - SolidFrame project hub, matching light/dark workspace palettes, original mark and fixture illustration, branded loading and recoverable startup errors. - A compact modeling ribbon with distinct command/menu targets, visible keyboard focus, and keyboard navigation through tool menus. - One-click native fixture with editable Box/Cylinder nodes, serialization support, and one undoable creation transaction. diff --git a/docs/mechanical-parts.md b/docs/mechanical-parts.md new file mode 100644 index 0000000..a2b62f6 --- /dev/null +++ b/docs/mechanical-parts.md @@ -0,0 +1,35 @@ +# Mechanical parts + +SolidFrame’s mechanical part builder creates one editable native solid from a compact set of dimensions. It adds a focused part-design workflow to the existing modeling workspace. + +## Create and revise + +1. Choose **Create mechanical part** on the home screen or in the modeling ribbon. +2. Choose a plate or bracket, then enter stock dimensions and hole layouts. For a bracket, configure the horizontal and vertical flanges separately. +3. Choose **Create part** to add one part. Opening or cancelling the editor does not create a document. +4. Select the part and choose **Edit mechanical part** to revise the complete configuration. Use **Apply changes**, then the document’s **Undo** and **Redo** controls as needed. +5. Save the project in this browser or download a native `.cd` file. Reopen a saved project from Recent projects to continue editing. + +Defaults are a 100 × 80 × 6 mm plate with four Ø8 mm holes at 60 × 40 mm pitch. The bracket adds a 60 mm total height and four vertical-flange Ø8 mm holes at 60 × 30 mm pitch. These are starting dimensions, not a standardized or approved part. + +## Geometry + +A plate is rectangular stock with a centered grid of round through holes. A bracket is an ideal sharp right-angle solid with separate hole grids on its horizontal and vertical flanges. The bracket height includes the base thickness. Grids are centered in the usable flange area, clear of the inside corner. + +Stock dimensions, hole diameter and pitch use millimetres. Rows and columns are integer counts. The complete configuration is validated before a native geometry operation: dimensions must be finite and positive, holes must not overlap or touch stock edges, and bracket holes must clear the adjoining flange. Stock and residual web must be at least 0.5 mm; dimensions are limited to 2,000 mm. A part may have at most 32 holes across its enabled grids. These limits bound work in the browser; they are not material or manufacturing requirements. + +## Editing and files + +The editor applies the whole configuration together. It builds candidate geometry before replacing the current valid parameters. A rejected edit leaves the old part intact. Creation and accepted edits participate in document undo/redo. No construction cylinders appear as additional project nodes. + +The native `.cd` document stores the builder parameters. Reopening it reconstructs the part with the same editable values. STEP and STL use the existing export commands and preserve geometry, not these builder settings. + +The part has parameter editing and document undo history. It does not expose separate blank/cut rows in the inherited parametric feature timeline. General sketch-based feature history remains available for other models. + +## Limits + +Supported shapes are rectangular plates and sharp right-angle brackets with regular round through-hole grids. There are no countersinks, threads, slots, corner fillets or arbitrary picked-face hole placement in this builder. + +A sharp bracket is an ideal geometric model. It is not a sheet-metal flat pattern and does not calculate bend radius, bend allowance, material properties, strength or manufacturing tolerances. Validate a design separately before fabrication. + +The implementation reuses Chili3D’s node, transaction, serialization, selection and export infrastructure and the existing OpenCascade geometry kernel. The part configuration, validation, construction workflow and dedicated editor are the new SolidFrame contribution. diff --git a/docs/solidframe-verification.md b/docs/solidframe-verification.md index 0082ee0..834e91f 100644 --- a/docs/solidframe-verification.md +++ b/docs/solidframe-verification.md @@ -34,3 +34,13 @@ The ribbon retains its commands and compact height, with softer tool surfaces, c The focused ribbon, dropdown and viewport checks passed **54 tests across six files**. The three new activation and disabled tests failed before implementation. Production application and bundled-plugin builds passed with the existing three bundle-size warnings. `npm run check` passed without applying fixes; inherited warnings and suggestions remain. `git diff --check` passed. Actual browser checks created a box, selected and edited the fixture base from 100 to 110 mm, launched Move using Enter and completed a two-point transform. Save reported success; a page reload and Recent-project reopen retained the new box, edited length and transform. The primitive dropdown opened and closed with Escape; the End snap checkbox switched off and on. Both themes were inspected in the running workspace. These checks cover the exercised controls and local-project workflow, not every CAD operation or exchange format. + +## Mechanical part builder (2026-10-07) + +The dedicated builder adds plates and sharp right-angle brackets with independent horizontal and vertical through-hole grids. The complete configuration is checked before kernel operations, and candidate geometry is built before replacing saved parameters. Shape wrappers are regenerated during undo/redo; undo records retain parameter JSON. This is parameter editing, not an extension of the inherited operation timeline. + +The focused 34-test run passed across four files, covering the new geometry, modal and commands plus the existing native fixture. Real OpenCascade tests check analytical volume, hole-center containment, closed shells and valid solids; reject invalid layouts before native execution; exercise edit failure preservation and undo/redo; reconstruct a nondefault bracket from serialized parameters; roundtrip STEP geometry; validate binary STL structure; and construct a 32-hole part. TypeScript and the application plus bundled-plugin builds passed. Existing bundle-size warnings remain. + +Native browser checks created a bracket with six horizontal Ø8 mm holes and four vertical Ø10 mm holes, then edited length from 95 to 110 mm. Undo and Redo restored each length in the editor. A fresh application page reopened the saved project from Recent projects with width 120, length 110, total height 75, thickness 6 mm, both enabled layouts and their different dimensions intact. The model tree showed one part. The 390-pixel editor fit without horizontal overflow, showed a rejected layout inline and allowed cancellation. + +The native browser reached the existing STEP export confirmation and reported Downloading. Save To File also reported Downloading, but the current in-app browser did not provide completed download artifacts. These are export-flow observations, not verified browser file downloads or imports. STEP roundtrip, STL bytes and native-node serialization were verified separately through the real local kernel tests. No third-party model, manufacturing process, material strength, or every-browser behavior is certified by these fixtures. diff --git a/packages/app/src/bodys/fabricationParameters.ts b/packages/app/src/bodys/fabricationParameters.ts new file mode 100644 index 0000000..9ae5488 --- /dev/null +++ b/packages/app/src/bodys/fabricationParameters.ts @@ -0,0 +1,178 @@ +// Part of the Chili3d Project, under the AGPL-3.0 License. +// See LICENSE file in the project root for full license information. + +import { Result } from "@chili3d/core"; + +export interface HoleGrid { + enabled: boolean; + columns: number; + rows: number; + pitchX: number; + pitchY: number; + diameter: number; +} + +export interface FabricationParameters { + kind: "plate" | "bracket"; + width: number; + length: number; + height: number; + thickness: number; + horizontal: HoleGrid; + vertical: HoleGrid; +} + +export const FABRICATION_MIN_WEB = 0.5; +export const FABRICATION_MAX_DIMENSION = 2000; +export const FABRICATION_MAX_HOLES = 32; + +export function defaultFabricationParameters(kind: "plate" | "bracket" = "plate"): FabricationParameters { + return { + kind, + width: 100, + length: 80, + height: 60, + thickness: 6, + horizontal: { enabled: true, columns: 2, rows: 2, pitchX: 60, pitchY: 40, diameter: 8 }, + vertical: { enabled: true, columns: 2, rows: 2, pitchX: 60, pitchY: 30, diameter: 8 }, + }; +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function dimension(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isFinite(value) && + value >= FABRICATION_MIN_WEB && + value <= FABRICATION_MAX_DIMENSION + ); +} + +function readGrid(value: unknown, fallback: HoleGrid, active: boolean): Result { + if (!record(value) || typeof value["enabled"] !== "boolean") { + return Result.err("A hole layout must include an enabled setting."); + } + if (!active || !value["enabled"]) { + const count = (key: "columns" | "rows") => + typeof value[key] === "number" && + Number.isInteger(value[key]) && + value[key] >= 1 && + value[key] <= FABRICATION_MAX_HOLES + ? value[key] + : fallback[key]; + return Result.ok({ + enabled: value["enabled"], + columns: count("columns"), + rows: count("rows"), + pitchX: dimension(value["pitchX"]) ? value["pitchX"] : fallback.pitchX, + pitchY: dimension(value["pitchY"]) ? value["pitchY"] : fallback.pitchY, + diameter: dimension(value["diameter"]) ? value["diameter"] : fallback.diameter, + }); + } + for (const key of ["columns", "rows"] as const) { + if ( + typeof value[key] !== "number" || + !Number.isInteger(value[key]) || + value[key] < 1 || + value[key] > FABRICATION_MAX_HOLES + ) { + return Result.err("Hole rows and columns must be whole numbers from 1 to 32."); + } + } + for (const key of ["pitchX", "pitchY", "diameter"] as const) { + if (!dimension(value[key])) return Result.err("Hole diameter and pitches must be 0.5 to 2,000 mm."); + } + return Result.ok({ + enabled: true, + columns: value["columns"] as number, + rows: value["rows"] as number, + pitchX: value["pitchX"] as number, + pitchY: value["pitchY"] as number, + diameter: value["diameter"] as number, + }); +} + +/** Validate the complete coupled layout before any native kernel call. Also strips unknown saved fields. */ +export function validateFabricationParameters(value: unknown): Result { + if (!record(value) || (value["kind"] !== "plate" && value["kind"] !== "bracket")) { + return Result.err("Choose a rectangular plate or right-angle bracket."); + } + for (const key of ["width", "length", "thickness"] as const) { + if (!dimension(value[key])) return Result.err("Stock dimensions must be 0.5 to 2,000 mm."); + } + const defaults = defaultFabricationParameters(value["kind"]); + const height = + value["kind"] === "plate" && !dimension(value["height"]) ? defaults.height : value["height"]; + if (!dimension(height)) return Result.err("Bracket height must be 0.5 to 2,000 mm."); + const width = value["width"] as number; + const length = value["length"] as number; + const thickness = value["thickness"] as number; + if ( + value["kind"] === "bracket" && + (length - thickness < FABRICATION_MIN_WEB || height - thickness < FABRICATION_MIN_WEB) + ) { + return Result.err("Bracket length and total height must exceed thickness by at least 0.5 mm."); + } + const horizontal = readGrid(value["horizontal"], defaults.horizontal, true); + if (!horizontal.isOk) return Result.err(horizontal.error); + const vertical = readGrid(value["vertical"], defaults.vertical, value["kind"] === "bracket"); + if (!vertical.isOk) return Result.err(vertical.error); + const parameters: FabricationParameters = { + kind: value["kind"], + width, + length, + height, + thickness, + horizontal: horizontal.value, + vertical: vertical.value, + }; + let holes = 0; + for (const flange of ["horizontal", "vertical"] as const) { + if (flange === "vertical" && parameters.kind === "plate") continue; + const grid = parameters[flange]; + if (!grid.enabled) continue; + holes += grid.columns * grid.rows; + const span = + (flange === "horizontal" ? length : height) - (parameters.kind === "bracket" ? thickness : 0); + if ( + (grid.columns > 1 && grid.pitchX - grid.diameter < FABRICATION_MIN_WEB) || + (grid.rows > 1 && grid.pitchY - grid.diameter < FABRICATION_MIN_WEB) + ) { + return Result.err("Leave at least 0.5 mm of stock between neighboring holes."); + } + if ( + (grid.columns - 1) * grid.pitchX + grid.diameter + 2 * FABRICATION_MIN_WEB > width || + (grid.rows - 1) * grid.pitchY + grid.diameter + 2 * FABRICATION_MIN_WEB > span + ) { + return Result.err( + `${flange === "horizontal" ? "Horizontal" : "Vertical"} holes need at least 0.5 mm clearance from every edge and the bracket corner.`, + ); + } + } + if (holes > FABRICATION_MAX_HOLES) return Result.err("Use at most 32 holes across both flange layouts."); + return Result.ok(parameters); +} + +/** Coordinates in the flange's own drawing: x across width, y along length or total height. */ +export function fabricationHoleCenters( + parameters: FabricationParameters, + flange: "horizontal" | "vertical", +): { x: number; y: number }[] { + if (flange === "vertical" && parameters.kind === "plate") return []; + const grid = parameters[flange]; + if (!grid.enabled) return []; + const offset = parameters.kind === "bracket" ? parameters.thickness : 0; + const span = (flange === "horizontal" ? parameters.length : parameters.height) - offset; + const startX = (parameters.width - (grid.columns - 1) * grid.pitchX) / 2; + const startY = offset + (span - (grid.rows - 1) * grid.pitchY) / 2; + const centers: { x: number; y: number }[] = []; + for (let row = 0; row < grid.rows; row++) { + for (let column = 0; column < grid.columns; column++) { + centers.push({ x: startX + column * grid.pitchX, y: startY + row * grid.pitchY }); + } + } + return centers; +} diff --git a/packages/app/src/bodys/fabricationPart.ts b/packages/app/src/bodys/fabricationPart.ts new file mode 100644 index 0000000..b515afb --- /dev/null +++ b/packages/app/src/bodys/fabricationPart.ts @@ -0,0 +1,151 @@ +// Part of the Chili3d Project, under the AGPL-3.0 License. +// See LICENSE file in the project root for full license information. + +import { + type I18nKeys, + type IShape, + ParameterShapeNode, + type ParameterShapeNodeOptions, + Plane, + Result, + ShapeTypes, + serializable, + serialize, + Transaction, + XYZ, +} from "@chili3d/core"; +import { + defaultFabricationParameters, + type FabricationParameters, + fabricationHoleCenters, + validateFabricationParameters, +} from "./fabricationParameters"; + +/** One solid, with all temporary stock and cutting tools owned by this operation. */ +export function buildFabricationShape(input: unknown): Result { + const validation = validateFabricationParameters(input); + if (!validation.isOk) return Result.err(validation.error); + const p = validation.value; + const temporaries = new Set(); + let output: IShape | undefined; + const own = (result: Result): IShape => { + if (!result.isOk) throw new Error(result.error); + temporaries.add(result.value); + return result.value; + }; + try { + let stock = own(shapeFactory.box(Plane.XY, p.width, p.length, p.thickness)); + if (p.kind === "bracket") { + const upright = own(shapeFactory.box(Plane.XY, p.width, p.thickness, p.height)); + stock = own(shapeFactory.booleanFuse([stock], [upright], false)); + } + const tools: IShape[] = []; + const overshoot = Math.max(0.01, p.thickness * 0.001); + for (const flange of ["horizontal", "vertical"] as const) { + for (const center of fabricationHoleCenters(p, flange)) { + const origin = + flange === "horizontal" + ? new XYZ({ x: center.x, y: center.y, z: -overshoot }) + : new XYZ({ x: center.x, y: -overshoot, z: center.y }); + tools.push( + own( + shapeFactory.cylinder( + flange === "horizontal" ? XYZ.unitZ : XYZ.unitY, + origin, + p[flange].diameter / 2, + p.thickness + 2 * overshoot, + ), + ), + ); + } + } + const result = tools.length ? own(shapeFactory.booleanCut([stock], tools)) : stock; + if (result.isNull() || !result.checkShape()) + return Result.err("The part could not form a valid solid."); + // OCCT boolean operations may wrap a single solid in a compound. + if (result.shapeType === ShapeTypes.solid) { + output = result; + } else { + const solids = result.findSubShapes(ShapeTypes.solid); + solids.forEach((solid) => temporaries.add(solid)); + if (solids.length !== 1) return Result.err("The part must contain exactly one connected solid."); + output = solids[0]; + } + // BRep_Tool::IsClosed reads the topology flag on a solid. Check its shells instead. + const shells = output.findSubShapes(ShapeTypes.shell); + shells.forEach((shell) => temporaries.add(shell)); + if (shells.length === 0 || shells.some((shell) => !shell.isClosed()) || !(output.volume() > 0)) { + output = undefined; + return Result.err("The part must be a closed solid with positive volume."); + } + return Result.ok(output); + } catch (error) { + output = undefined; + return Result.err(error instanceof Error ? error.message : "The geometry operation failed."); + } finally { + for (const shape of temporaries) if (shape !== output) shape.dispose(); + } +} + +export interface FabricationPartOptions extends ParameterShapeNodeOptions { + parameters?: FabricationParameters; + parametersJson?: string; + /** A prebuilt candidate, transferred to this node during creation. Not serialized. */ + initialShape?: IShape; +} + +@serializable() +export class FabricationPartNode extends ParameterShapeNode { + constructor(options: FabricationPartOptions) { + super(options); + const validation = validateFabricationParameters( + options.parametersJson === undefined + ? (options.parameters ?? defaultFabricationParameters()) + : JSON.parse(options.parametersJson), + ); + if (!validation.isOk) throw new Error(validation.error); + this.setPrivateValue("parametersJson", JSON.stringify(validation.value)); + if (options.initialShape) this._shape = Result.ok(options.initialShape); + } + + override display(): I18nKeys { + return "body.fabricationPart"; + } + + @serialize() + get parametersJson(): string { + return this.getPrivateValue("parametersJson"); + } + set parametersJson(value: string) { + const result = this.applyParameters(JSON.parse(value)); + if (!result.isOk) throw new Error(result.error); + } + + get parameters(): FabricationParameters { + return JSON.parse(this.parametersJson); + } + + /** Only the parameter JSON enters undo history; shapes are regenerated, never stored after disposal. */ + applyParameters(input: unknown): Result { + const validation = validateFabricationParameters(input); + if (!validation.isOk) return Result.err(validation.error); + const json = JSON.stringify(validation.value); + if (json === this.parametersJson) return Result.ok(false); + const candidate = buildFabricationShape(validation.value); + if (!candidate.isOk) return Result.err(candidate.error); + const previous = this._shape; + Transaction.execute(this.document, "Edit mechanical part", () => { + this.setProperty("parametersJson", json, () => { + this._shape = candidate; + this._mesh = undefined; + this.emitPropertyChanged("shape", previous); + }); + }); + previous.unchecked()?.dispose(); + return Result.ok(true); + } + + protected override generateShape(): Result { + return buildFabricationShape(this.parameters); + } +} diff --git a/packages/app/src/commands/fabrication.ts b/packages/app/src/commands/fabrication.ts new file mode 100644 index 0000000..b4943b6 --- /dev/null +++ b/packages/app/src/commands/fabrication.ts @@ -0,0 +1,71 @@ +// Part of the Chili3d Project, under the AGPL-3.0 License. +// See LICENSE file in the project root for full license information. + +import { command, I18n, type IApplication, type ICommand, PubSub, Result, Transaction } from "@chili3d/core"; +import { defaultFabricationParameters } from "../bodys/fabricationParameters"; +import { buildFabricationShape, FabricationPartNode } from "../bodys/fabricationPart"; +import { showFabricationDialog } from "./fabricationDialog"; + +@command({ key: "fabrication.create", icon: "icon-box", isApplicationCommand: true }) +export class CreateFabricationPart implements ICommand { + async execute(application: IApplication): Promise { + const originalDocument = application.activeView?.document; + await showFabricationDialog(defaultFabricationParameters(), "create", async (parameters) => { + if (originalDocument && !application.documents.has(originalDocument)) { + return Result.err(I18n.translate("fabrication.documentClosed")); + } + const candidate = buildFabricationShape(parameters); + if (!candidate.isOk) return Result.err(candidate.error); + let node: FabricationPartNode | undefined; + try { + // A failed geometry operation must not leave an empty document behind. + const document = + originalDocument ?? + application.activeView?.document ?? + (await application.newDocument(I18n.translate("fabrication.documentName"))); + node = new FabricationPartNode({ document, parameters, initialShape: candidate.value }); + const part = node; + Transaction.execute(document, I18n.translate("command.fabrication.create"), () => { + document.modelManager.addNode(part); + }); + } catch { + if (node) node.dispose(); + else candidate.value.dispose(); + return Result.err(I18n.translate("fabrication.failed")); + } + node.document.selection.setSelectedNodes([node], false); + node.document.visual.update(); + if (application.activeView?.document === node.document) { + application.activeView.cameraController.fitContent(); + } + PubSub.default.pub("displayHome", false); + return Result.ok(true); + }); + } +} + +@command({ key: "fabrication.edit", icon: "icon-edit" }) +export class EditFabricationPart implements ICommand { + async execute(application: IApplication): Promise { + const document = application.activeView?.document; + const selected = document?.selection.getSelectedNodes() ?? []; + const part = selected[0]; + if (!document || selected.length !== 1 || !(part instanceof FabricationPartNode)) { + PubSub.default.pub("showToast", "fabrication.selectPart"); + return; + } + await showFabricationDialog(part.parameters, "edit", async (parameters) => { + if (!application.documents.has(document)) { + return Result.err(I18n.translate("fabrication.documentClosed")); + } + if (document.modelManager.findNode((node) => node === part) !== part) { + return Result.err(I18n.translate("fabrication.selectPart")); + } + const result = part.applyParameters(parameters); + if (!result.isOk) return result; + document.visual.update(); + // Applying an unchanged valid draft is also a successful submission. + return Result.ok(true); + }); + } +} diff --git a/packages/app/src/commands/fabricationDialog.module.css b/packages/app/src/commands/fabricationDialog.module.css new file mode 100644 index 0000000..31d6389 --- /dev/null +++ b/packages/app/src/commands/fabricationDialog.module.css @@ -0,0 +1,207 @@ +.root { + box-sizing: border-box; + width: min(900px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + padding: 0; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--background-color); + color: var(--foreground-color); + box-shadow: 0 18px 80px #0007; +} +.root::backdrop { + background: #0008; +} +.form { + display: flex; + flex-direction: column; + max-height: inherit; +} +.header { + flex-shrink: 0; + padding: 22px 24px 16px; + border-bottom: 1px solid var(--border-color); +} +.header h2 { + margin: 0 0 8px; + font-size: 22px; +} +.header p, +.preview p { + margin: 0; + color: var(--foreground-secondary-color); + font-size: 12px; + line-height: 1.6; +} +.body { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); + gap: 24px; + overflow: auto; + min-height: 0; + padding: 20px 24px; +} +.fields { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; +} +.dimensions, +.holeGrid { + margin: 0; + padding: 14px; + border: 1px solid var(--border-color); + border-radius: 7px; + min-width: 0; +} +.dimensions, +.gridInputs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.dimensions legend, +.holeGrid legend { + padding: 0 6px; + font-size: 13px; + font-weight: 650; +} +.field { + display: flex; + flex-direction: column; + gap: 7px; + min-width: 0; + font-size: 12px; +} +.field input, +.field select { + box-sizing: border-box; + width: 100%; + min-height: 37px; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--panel-background-color); + color: var(--foreground-color); + font: inherit; + font-size: 14px; +} +.field input:disabled { + opacity: 0.45; +} +.toggle { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 14px; + font-size: 12px; +} +.toggle input { + width: 16px; + height: 16px; + accent-color: var(--primary-color); +} +.preview { + min-width: 0; + padding: 18px; + border-radius: 8px; + background: var(--panel-background-color); + align-self: start; +} +.preview h3 { + margin: 0 0 8px; + font-size: 14px; +} +.preview svg { + display: block; + width: 100%; + max-height: 230px; + margin-top: 20px; +} +.stock { + fill: var(--background-color); + stroke: var(--primary-color); + stroke-width: 1.5px; + vector-effect: non-scaling-stroke; +} +.corner { + fill: var(--primary-color); + opacity: 0.22; +} +.hole { + fill: var(--panel-background-color); + stroke: var(--primary-color); + stroke-width: 1.5px; + vector-effect: non-scaling-stroke; +} +.preview .caption { + margin-top: 8px; + text-align: center; + font-size: 11px; +} +.footer { + flex-shrink: 0; + padding: 14px 24px 20px; + border-top: 1px solid var(--border-color); +} +.error { + margin: 0 0 10px; + color: var(--error-color, #d94a4a); + font-size: 12px; + line-height: 1.5; +} +.error:empty { + display: none; +} +.actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} +.actions button { + min-height: 38px; + padding: 9px 20px; + border: 1px solid var(--border-color); + border-radius: 5px; + background: var(--panel-background-color); + color: var(--foreground-color); + font: inherit; + font-size: 13px; + cursor: pointer; +} +.actions .primary { + background: var(--button-primary-background); + color: white; + border-color: transparent; +} +.actions button:disabled { + opacity: 0.55; + cursor: wait; +} +.root :is(input, select, button):focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} +.root [hidden] { + display: none; +} +@media (max-width: 640px) { + .header, + .footer { + padding: 16px; + } + .body { + grid-template-columns: minmax(0, 1fr); + padding: 16px; + gap: 18px; + } + .field input, + .field select { + font-size: 16px; + } + .preview svg { + max-height: 190px; + } +} diff --git a/packages/app/src/commands/fabricationDialog.ts b/packages/app/src/commands/fabricationDialog.ts new file mode 100644 index 0000000..9eb5ba3 --- /dev/null +++ b/packages/app/src/commands/fabricationDialog.ts @@ -0,0 +1,328 @@ +// Part of the Chili3d Project, under the AGPL-3.0 License. +// See LICENSE file in the project root for full license information. + +import { I18n, type I18nKeys, type Result } from "@chili3d/core"; +import { + type FabricationParameters, + fabricationHoleCenters, + type HoleGrid, + validateFabricationParameters, +} from "../bodys/fabricationParameters"; +import style from "./fabricationDialog.module.css"; + +let dialogSequence = 0; + +/** Edits a local draft. The submit callback is the only operation that builds geometry. */ +export function showFabricationDialog( + initial: FabricationParameters, + mode: "create" | "edit", + submit: (parameters: FabricationParameters) => Promise>, +): Promise { + let resolveClosed!: () => void; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const id = `fabrication-${++dialogSequence}`; + const previousFocus = document.activeElement; + const dialog = document.createElement("dialog"); + dialog.className = style.root; + dialog.dataset["fabricationDialog"] = mode; + dialog.setAttribute("aria-labelledby", `${id}-title`); + dialog.setAttribute("aria-describedby", `${id}-description`); + const form = document.createElement("form"); + form.className = style.form; + form.dataset["fabricationForm"] = ""; + form.noValidate = true; + const heading = document.createElement("h2"); + heading.id = `${id}-title`; + heading.textContent = I18n.translate( + mode === "create" ? "command.fabrication.create" : "command.fabrication.edit", + ); + const description = document.createElement("p"); + description.id = `${id}-description`; + description.textContent = I18n.translate("fabrication.description"); + const header = document.createElement("header"); + header.className = style.header; + header.append(heading, description); + + const body = document.createElement("div"); + body.className = style.body; + const fields = document.createElement("div"); + fields.className = style.fields; + const dimensions = document.createElement("fieldset"); + dimensions.className = style.dimensions; + const dimensionsLegend = document.createElement("legend"); + dimensionsLegend.textContent = I18n.translate("fabrication.dimensions"); + dimensions.append(dimensionsLegend); + const inputs = new Map(); + const kind = document.createElement("select"); + kind.id = `${id}-kind`; + kind.name = "kind"; + for (const value of ["plate", "bracket"] as const) { + const option = document.createElement("option"); + option.value = value; + option.textContent = I18n.translate(`fabrication.${value}`); + kind.append(option); + } + kind.value = initial.kind; + dimensions.append(field("fabrication.kind", kind)); + + function numberField(key: I18nKeys, name: string, value: number, count = false): HTMLElement { + const input = document.createElement("input"); + input.type = "number"; + input.id = `${id}-${name}`; + input.name = name; + input.value = String(value); + input.step = count ? "1" : "any"; + input.min = count ? "1" : "0.5"; + input.max = count ? "32" : "2000"; + input.inputMode = count ? "numeric" : "decimal"; + input.required = true; + inputs.set(name, input); + return field(key, input, I18n.translate(count ? "fabrication.count" : "fabrication.mm")); + } + + dimensions.append( + numberField("fabrication.width", "width", initial.width), + numberField("fabrication.length", "length", initial.length), + ); + const heightField = numberField("fabrication.height", "height", initial.height); + dimensions.append(heightField, numberField("fabrication.thickness", "thickness", initial.thickness)); + fields.append(dimensions); + + function gridFields(flange: "horizontal" | "vertical", grid: HoleGrid): HTMLFieldSetElement { + const fieldset = document.createElement("fieldset"); + fieldset.className = style.holeGrid; + fieldset.dataset["flange"] = flange; + const legend = document.createElement("legend"); + legend.textContent = I18n.translate(`fabrication.${flange}`); + const enabled = document.createElement("input"); + enabled.type = "checkbox"; + enabled.id = `${id}-${flange}-enabled`; + enabled.name = `${flange}.enabled`; + enabled.checked = grid.enabled; + inputs.set(enabled.name, enabled); + const toggle = document.createElement("label"); + toggle.className = style.toggle; + toggle.htmlFor = enabled.id; + toggle.append(enabled, I18n.translate("fabrication.enableHoles")); + const layout = document.createElement("div"); + layout.className = style.gridInputs; + layout.append( + numberField("fabrication.columns", `${flange}.columns`, grid.columns, true), + numberField("fabrication.rows", `${flange}.rows`, grid.rows, true), + numberField("fabrication.pitchX", `${flange}.pitchX`, grid.pitchX), + numberField("fabrication.pitchY", `${flange}.pitchY`, grid.pitchY), + numberField("fabrication.diameter", `${flange}.diameter`, grid.diameter), + ); + fieldset.append(legend, toggle, layout); + return fieldset; + } + + const horizontal = gridFields("horizontal", initial.horizontal); + const vertical = gridFields("vertical", initial.vertical); + fields.append(horizontal, vertical); + const preview = document.createElement("aside"); + preview.className = style.preview; + preview.setAttribute("aria-label", I18n.translate("fabrication.preview")); + body.append(fields, preview); + const footer = document.createElement("footer"); + footer.className = style.footer; + const error = document.createElement("p"); + error.id = `${id}-error`; + error.className = style.error; + error.setAttribute("role", "alert"); + error.setAttribute("aria-live", "polite"); + error.tabIndex = -1; + form.setAttribute("aria-describedby", error.id); + const actions = document.createElement("div"); + actions.className = style.actions; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.textContent = I18n.translate("common.cancel"); + cancel.addEventListener("click", () => dialog.close()); + const apply = document.createElement("button"); + apply.type = "submit"; + apply.className = style.primary; + const actionKey = mode === "create" ? "fabrication.create" : "fabrication.apply"; + apply.textContent = I18n.translate(actionKey); + actions.append(cancel, apply); + footer.append(error, actions); + form.append(header, body, footer); + dialog.append(form); + let busy = false; + + function readGrid(flange: "horizontal" | "vertical"): HoleGrid { + return { + enabled: inputs.get(`${flange}.enabled`)!.checked, + columns: inputs.get(`${flange}.columns`)!.valueAsNumber, + rows: inputs.get(`${flange}.rows`)!.valueAsNumber, + pitchX: inputs.get(`${flange}.pitchX`)!.valueAsNumber, + pitchY: inputs.get(`${flange}.pitchY`)!.valueAsNumber, + diameter: inputs.get(`${flange}.diameter`)!.valueAsNumber, + }; + } + + function readParameters(): FabricationParameters { + return { + kind: kind.value as "plate" | "bracket", + width: inputs.get("width")!.valueAsNumber, + length: inputs.get("length")!.valueAsNumber, + height: inputs.get("height")!.valueAsNumber, + thickness: inputs.get("thickness")!.valueAsNumber, + horizontal: readGrid("horizontal"), + vertical: readGrid("vertical"), + }; + } + + function updateDraft(): void { + const bracket = kind.value === "bracket"; + heightField.hidden = !bracket; + inputs.get("height")!.disabled = !bracket; + vertical.hidden = !bracket; + for (const [flange, grid] of [ + ["horizontal", horizontal], + ["vertical", vertical], + ] as const) { + const enabled = inputs.get(`${flange}.enabled`)!; + enabled.disabled = flange === "vertical" && !bracket; + for (const input of grid.querySelectorAll('input[type="number"]')) { + input.disabled = enabled.disabled || !enabled.checked; + } + } + const parameters = readParameters(); + const validation = validateFabricationParameters(parameters); + error.textContent = validation.isOk ? "" : validation.error; + renderPreview(preview, validation.isOk ? validation.value : undefined); + } + + form.addEventListener("input", updateDraft); + form.addEventListener("change", updateDraft); + // Keep document shortcuts away from inputs, including Delete and Escape. + dialog.addEventListener("keydown", (event) => event.stopPropagation()); + dialog.addEventListener("cancel", (event) => { + if (busy) event.preventDefault(); + }); + dialog.addEventListener("close", () => { + dialog.remove(); + if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus(); + resolveClosed(); + }); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (busy) return; + const validation = validateFabricationParameters(readParameters()); + if (!validation.isOk) { + error.textContent = validation.error; + const invalid = form.querySelector("input:enabled:invalid"); + (invalid ?? error).focus(); + return; + } + busy = true; + form.setAttribute("aria-busy", "true"); + const controls = [ + ...form.querySelectorAll( + "input, select, button", + ), + ]; + controls.forEach((control) => { + control.disabled = true; + }); + apply.textContent = I18n.translate("fabrication.building"); + error.textContent = ""; + // Paint the busy state before the synchronous CAD operation begins. + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + try { + const result = await submit(validation.value); + if (result.isOk && result.value) { + dialog.close(); + } else { + error.textContent = result.isOk ? I18n.translate("fabrication.failed") : result.error; + } + } catch { + error.textContent = I18n.translate("fabrication.failed"); + } finally { + busy = false; + form.removeAttribute("aria-busy"); + controls.forEach((control) => { + control.disabled = false; + }); + apply.textContent = I18n.translate(actionKey); + // Restore the draft-dependent disabled controls without overwriting a kernel error. + const failure = error.textContent; + updateDraft(); + error.textContent = failure; + if (dialog.open && failure) error.focus(); + } + }); + updateDraft(); + document.body.append(dialog); + dialog.showModal(); + kind.focus(); + return closed; +} + +function field(key: I18nKeys, control: HTMLInputElement | HTMLSelectElement, unit?: string): HTMLElement { + const label = document.createElement("label"); + label.className = style.field; + label.htmlFor = control.id; + const text = document.createElement("span"); + text.textContent = `${I18n.translate(key)}${unit ? ` (${unit})` : ""}`; + label.append(text, control); + return label; +} + +function renderPreview(container: HTMLElement, parameters?: FabricationParameters): void { + container.replaceChildren(); + const title = document.createElement("h3"); + title.textContent = I18n.translate("fabrication.preview"); + const note = document.createElement("p"); + note.textContent = I18n.translate("fabrication.layoutNote"); + container.append(title, note); + if (!parameters) { + const invalid = document.createElement("p"); + invalid.textContent = I18n.translate("fabrication.previewInvalid"); + container.append(invalid); + return; + } + const ns = "http://www.w3.org/2000/svg"; + for (const flange of parameters.kind === "bracket" + ? (["horizontal", "vertical"] as const) + : (["horizontal"] as const)) { + const span = flange === "horizontal" ? parameters.length : parameters.height; + const padding = Math.max(parameters.width, span) * 0.06; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute( + "viewBox", + `${-padding} ${-padding} ${parameters.width + 2 * padding} ${span + 2 * padding}`, + ); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", I18n.translate(`fabrication.${flange}`)); + const rect = document.createElementNS(ns, "rect"); + rect.setAttribute("width", String(parameters.width)); + rect.setAttribute("height", String(span)); + rect.classList.add(style.stock); + svg.append(rect); + if (parameters.kind === "bracket") { + const corner = document.createElementNS(ns, "rect"); + corner.setAttribute("width", String(parameters.width)); + corner.setAttribute("height", String(parameters.thickness)); + corner.classList.add(style.corner); + svg.append(corner); + } + for (const center of fabricationHoleCenters(parameters, flange)) { + const circle = document.createElementNS(ns, "circle"); + circle.setAttribute("cx", String(center.x)); + circle.setAttribute("cy", String(center.y)); + circle.setAttribute("r", String(parameters[flange].diameter / 2)); + circle.classList.add(style.hole); + svg.append(circle); + } + const caption = document.createElement("p"); + caption.className = style.caption; + caption.textContent = `${I18n.translate(`fabrication.${flange}`)} · ${parameters.width} × ${span} mm`; + container.append(svg, caption); + } +} diff --git a/packages/app/src/commands/index.ts b/packages/app/src/commands/index.ts index 3d7d5f9..47e43fc 100644 --- a/packages/app/src/commands/index.ts +++ b/packages/app/src/commands/index.ts @@ -9,6 +9,7 @@ export * from "./create"; export * from "./createActCommand"; export * from "./createCommand"; export * from "./delete"; +export * from "./fabrication"; export * from "./folder"; export * from "./importExport"; export * from "./measure"; diff --git a/packages/app/test/bodys/fabricationPart.test.ts b/packages/app/test/bodys/fabricationPart.test.ts new file mode 100644 index 0000000..44ed50f --- /dev/null +++ b/packages/app/test/bodys/fabricationPart.test.ts @@ -0,0 +1,232 @@ +// Part of the Chili3d Project, under the AGPL-3.0 License. +// See LICENSE file in the project root for full license information. + +import { EditableShapeNode, type ISolid, Result, Serializer, ShapeTypes, XYZ } from "@chili3d/core"; +import { TestDocument } from "@chili3d/core/test-utils"; +import { OccShapeConverter, ShapeFactory } from "@chili3d/wasm"; +import { afterEach, beforeEach, expect, rs, test } from "@rstest/core"; +import "../../../wasm/test/setup"; +import { + defaultFabricationParameters, + type FabricationParameters, + fabricationHoleCenters, + validateFabricationParameters, +} from "../../src/bodys/fabricationParameters"; +import { buildFabricationShape, FabricationPartNode } from "../../src/bodys/fabricationPart"; + +beforeEach(() => { + rs.stubGlobal("shapeFactory", new ShapeFactory()); +}); +afterEach(() => { + rs.unstubAllGlobals(); + rs.restoreAllMocks(); +}); + +function expectedVolume(p: FabricationParameters): number { + const blank = + p.kind === "plate" + ? p.width * p.length * p.thickness + : p.width * p.thickness * (p.length + p.height - p.thickness); + return ( + blank - + ["horizontal", "vertical"].reduce((total, flange) => { + const f = flange as "horizontal" | "vertical"; + return ( + total + fabricationHoleCenters(p, f).length * Math.PI * (p[f].diameter / 2) ** 2 * p.thickness + ); + }, 0) + ); +} + +test.each([ + "plate", + "bracket", +] as const)("%s is a valid closed solid with analytical volume and through holes", (kind) => { + const p = defaultFabricationParameters(kind); + const built = buildFabricationShape(p); + expect(built.isOk, built.isOk ? "" : built.error).toBe(true); + const solid = built.value as ISolid; + try { + expect(solid.shapeType).toBe(ShapeTypes.solid); + expect(solid.checkShape()).toBe(true); + const shells = solid.findSubShapes(ShapeTypes.shell); + expect(shells.length).toBeGreaterThan(0); + expect(shells.every((shell) => shell.isClosed())).toBe(true); + shells.forEach((shell) => shell.dispose()); + expect(solid.volume()).toBeCloseTo(expectedVolume(p), 5); + for (const flange of ["horizontal", "vertical"] as const) { + for (const center of fabricationHoleCenters(p, flange)) { + const point = + flange === "horizontal" + ? new XYZ({ x: center.x, y: center.y, z: p.thickness / 2 }) + : new XYZ({ x: center.x, y: p.thickness / 2, z: center.y }); + expect(solid.containsPoint(point, false, 1e-7)).toBe(false); + } + } + expect(solid.containsPoint(new XYZ({ x: 1, y: 1, z: 1 }), false, 1e-7)).toBe(true); + } finally { + solid.dispose(); + } +}); + +test.each([ + ["nonfinite dimension", { width: Number.NaN }], + ["infinite dimension", { length: Number.POSITIVE_INFINITY }], + ["zero stock", { thickness: 0 }], + ["excessive dimension", { width: 2001 }], + ["fractional rows", { horizontal: { ...defaultFabricationParameters().horizontal, rows: 1.5 } }], + ["touching holes", { horizontal: { ...defaultFabricationParameters().horizontal, pitchX: 8 } }], + ["touching edge", { horizontal: { ...defaultFabricationParameters().horizontal, pitchX: 92 } }], + [ + "too many holes", + { + width: 1000, + length: 1000, + horizontal: { ...defaultFabricationParameters().horizontal, rows: 5, columns: 7 }, + }, + ], + ["string dimension", { width: "100" }], + ["invalid kind", { kind: "sphere" }], +])("rejects %s before entering the kernel", (_name, patch) => { + const box = rs.spyOn(shapeFactory, "box"); + const result = buildFabricationShape({ ...defaultFabricationParameters(), ...patch }); + expect(result.isOk).toBe(false); + expect(box).not.toHaveBeenCalled(); +}); + +test("bracket grids clear the corner and the total hole limit covers both flanges", () => { + const p = defaultFabricationParameters("bracket"); + expect(validateFabricationParameters({ ...p, length: 10 }).isOk).toBe(false); + expect(validateFabricationParameters({ ...p, height: p.thickness }).isOk).toBe(false); + expect( + validateFabricationParameters({ + ...p, + width: 1000, + length: 1000, + height: 1000, + horizontal: { ...p.horizontal, rows: 4, columns: 5 }, + vertical: { ...p.vertical, rows: 4, columns: 4 }, + }).isOk, + ).toBe(false); + const centers = fabricationHoleCenters(p, "vertical"); + expect(centers).toHaveLength(4); + expect(centers[0]).toEqual({ x: 20, y: 18 }); +}); + +test("disabled grids normalize unused drafts and create an undrilled blank", () => { + const p = defaultFabricationParameters("bracket"); + p.horizontal = { ...p.horizontal, enabled: false, rows: Number.NaN }; + p.vertical.enabled = false; + const validated = validateFabricationParameters(p); + expect(validated.isOk).toBe(true); + expect(Number.isFinite(validated.value.horizontal.rows)).toBe(true); + const built = buildFabricationShape(p); + expect(built.isOk, built.isOk ? "" : built.error).toBe(true); + expect(built.value.volume()).toBeCloseTo(80400, 5); + built.value.dispose(); +}); + +test("disabling a valid custom grid keeps its saved settings for later re-enabling", () => { + const p = defaultFabricationParameters("bracket"); + p.horizontal = { ...p.horizontal, enabled: false, columns: 3, pitchX: 25, diameter: 10 }; + const validated = validateFabricationParameters(p); + expect(validated.isOk).toBe(true); + expect(validated.value.horizontal).toEqual(p.horizontal); +}); + +test("an atomic nondefault bracket edit, undo/redo and serialization retain parameters and geometry", () => { + const document = new TestDocument(); + const node = new FabricationPartNode({ document, parameters: defaultFabricationParameters("bracket") }); + const old = node.parameters; + const originalVolume = node.shape.value.volume(); + const changed = { + ...old, + width: 120, + length: 95, + height: 75, + horizontal: { ...old.horizontal, columns: 3, pitchX: 35 }, + vertical: { ...old.vertical, diameter: 10 }, + }; + const undoCount = document.history.undoCount(); + expect(node.applyParameters(changed).isOk).toBe(true); + expect(document.history.undoCount()).toBe(undoCount + 1); + expect(node.parameters).toEqual(changed); + expect(node.shape.value.volume()).toBeCloseTo(expectedVolume(changed), 5); + document.history.undo(); + expect(node.parameters).toEqual(old); + expect(node.shape.value.volume()).toBeCloseTo(originalVolume, 5); + document.history.redo(); + expect(node.parameters).toEqual(changed); + expect(node.shape.value.volume()).toBeCloseTo(expectedVolume(changed), 5); + const restored = Serializer.deserializeObject( + document, + Serializer.serializeObject(node), + ) as FabricationPartNode; + expect(restored).toBeInstanceOf(FabricationPartNode); + expect(restored.parameters).toEqual(changed); + expect(restored.shape.value.volume()).toBeCloseTo(expectedVolume(changed), 5); + restored.dispose(); + node.dispose(); +}); + +test("invalid input and native failures preserve both geometry and undo history", () => { + const document = new TestDocument(); + const node = new FabricationPartNode({ document }); + const original = node.shape.value; + const json = node.parametersJson; + const count = document.history.undoCount(); + expect(node.applyParameters({ ...node.parameters, width: -1 }).isOk).toBe(false); + rs.spyOn(shapeFactory, "booleanCut").mockReturnValue(Result.err("Controlled native failure")); + expect(node.applyParameters({ ...node.parameters, width: 110 }).error).toBe("Controlled native failure"); + expect(node.parametersJson).toBe(json); + expect(node.shape.value).toBe(original); + expect(original.checkShape()).toBe(true); + expect(document.history.undoCount()).toBe(count); + node.dispose(); +}); + +test("corrupt saved parameters reject restoration before geometry execution", () => { + const box = rs.spyOn(shapeFactory, "box"); + expect( + () => new FabricationPartNode({ document: new TestDocument(), parametersJson: '{"kind":"plate"}' }), + ).toThrow("Stock dimensions"); + expect(box).not.toHaveBeenCalled(); +}); + +test("the bracket survives STEP exchange and exports a complete binary STL", () => { + const built = buildFabricationShape(defaultFabricationParameters("bracket")); + expect(built.isOk, built.isOk ? "" : built.error).toBe(true); + const converter = new OccShapeConverter(); + const step = converter.convertToSTEP(built.value); + expect(step.isOk).toBe(true); + const imported = converter.convertFromSTEP(new TestDocument(), new TextEncoder().encode(step.value)); + expect(imported.isOk).toBe(true); + const node = imported.value.firstChild; + expect(node).toBeInstanceOf(EditableShapeNode); + const restored = (node as EditableShapeNode).shape; + expect(restored.isOk).toBe(true); + expect(restored.value.checkShape()).toBe(true); + expect(restored.value.volume()).toBeCloseTo(built.value.volume(), 4); + const stl = converter.convertToSTL([built.value], { binary: true }); + expect(stl.isOk).toBe(true); + const triangles = new DataView(stl.value.buffer, stl.value.byteOffset).getUint32(80, true); + expect(triangles).toBeGreaterThan(0); + expect(stl.value.length).toBe(84 + 50 * triangles); + imported.value.dispose(); + built.value.dispose(); +}); + +test("the maximum 32-hole layout produces a valid solid with the expected removed volume", () => { + const p = defaultFabricationParameters(); + p.width = 400; + p.length = 200; + p.horizontal = { enabled: true, columns: 8, rows: 4, pitchX: 40, pitchY: 40, diameter: 8 }; + const start = performance.now(); + const built = buildFabricationShape(p); + const elapsed = performance.now() - start; + expect(built.isOk, built.isOk ? "" : built.error).toBe(true); + expect(built.value.checkShape()).toBe(true); + expect(built.value.volume()).toBeCloseTo(expectedVolume(p), 4); + console.info(`32-hole native part: ${elapsed.toFixed(1)} ms`); + built.value.dispose(); +}); diff --git a/packages/app/test/commands/fabrication.test.ts b/packages/app/test/commands/fabrication.test.ts new file mode 100644 index 0000000..74b9679 --- /dev/null +++ b/packages/app/test/commands/fabrication.test.ts @@ -0,0 +1,154 @@ +// Part of the Chili3d Project, under the AGPL-3.0 License. +// See LICENSE file in the project root for full license information. + +import { PubSub, Result } from "@chili3d/core"; +import { + createMockApplication, + createMockDocument, + createMockView, + MockShape, + TestDocument, +} from "@chili3d/core/test-utils"; +import { afterEach, beforeEach, expect, rs, test } from "@rstest/core"; +import { defaultFabricationParameters } from "../../src/bodys/fabricationParameters"; +import * as fabricationPart from "../../src/bodys/fabricationPart"; +import { FabricationPartNode } from "../../src/bodys/fabricationPart"; +import { CreateFabricationPart, EditFabricationPart } from "../../src/commands/fabrication"; +import { showFabricationDialog } from "../../src/commands/fabricationDialog"; + +rs.mock("../../src/commands/fabricationDialog", () => ({ showFabricationDialog: rs.fn() })); +const show = rs.mocked(showFabricationDialog); +const build = rs.fn(); +const documents: TestDocument[] = []; + +function context() { + const app = createMockApplication(); + const selection = createMockDocument().selection; + const document = new TestDocument({ application: app, selection }); + documents.push(document); + app.documents.add(document); + app.activeView = createMockView({ document }); + app.activeView.cameraController.fitContent = rs.fn(); + app.newDocument = rs.fn(async () => document); + return { app, document }; +} + +function submit() { + return show.mock.calls[0][2](defaultFabricationParameters()); +} + +beforeEach(() => { + show.mockReset().mockResolvedValue(undefined); + build.mockReset(); + rs.spyOn(fabricationPart, "buildFabricationShape").mockImplementation(build); +}); + +afterEach(() => { + for (const document of documents.splice(0)) document.dispose(); + rs.restoreAllMocks(); +}); + +test("creation cancels without allocating a document or building geometry", async () => { + const app = createMockApplication(); + app.newDocument = rs.fn(); + await new CreateFabricationPart().execute(app); + expect(app.newDocument).not.toHaveBeenCalled(); + expect(build).not.toHaveBeenCalled(); +}); + +test("a rejected shape does not create an empty document", async () => { + const app = createMockApplication(); + app.newDocument = rs.fn(); + build.mockReturnValue(Result.err("Kernel refused this part")); + await new CreateFabricationPart().execute(app); + expect((await submit()).error).toBe("Kernel refused this part"); + expect(app.newDocument).not.toHaveBeenCalled(); +}); + +test("creation reuses the active document, owns the prebuilt shape, and adds one undo step", async () => { + const { app, document } = context(); + const candidate = new MockShape(); + build.mockReturnValue(Result.ok(candidate)); + const select = rs.spyOn(document.selection, "setSelectedNodes"); + await new CreateFabricationPart().execute(app); + expect((await submit()).value).toBe(true); + const nodes = document.modelManager.findNodes(); + expect(nodes).toHaveLength(1); + expect(nodes[0]).toBeInstanceOf(FabricationPartNode); + expect((nodes[0] as FabricationPartNode).shape.value).toBe(candidate); + expect(build).toHaveBeenCalledTimes(1); + expect(app.newDocument).not.toHaveBeenCalled(); + expect(select).toHaveBeenCalledWith([nodes[0]], false); + expect(document.history.undoCount()).toBe(1); + document.history.undo(); + expect(document.modelManager.findNodes()).toHaveLength(0); + document.history.redo(); + expect(document.modelManager.findNodes()).toEqual(nodes); +}); + +test("home creation builds before allocating its first document", async () => { + const { app, document } = context(); + app.activeView = undefined; + const calls: string[] = []; + build.mockImplementation(() => { + calls.push("build"); + return Result.ok(new MockShape()); + }); + app.newDocument = rs.fn(async () => { + calls.push("document"); + return document; + }); + await new CreateFabricationPart().execute(app); + expect((await submit()).value).toBe(true); + expect(calls).toEqual(["build", "document"]); + expect(app.newDocument).toHaveBeenCalledExactlyOnceWith("fabrication.documentName"); +}); + +test("document creation failure disposes the unused candidate", async () => { + const app = createMockApplication(); + const candidate = new MockShape(); + const dispose = rs.spyOn(candidate, "dispose"); + build.mockReturnValue(Result.ok(candidate)); + app.newDocument = rs.fn(async () => { + throw new Error("Document unavailable"); + }); + await new CreateFabricationPart().execute(app); + expect((await submit()).isOk).toBe(false); + expect(dispose).toHaveBeenCalledTimes(1); +}); + +test("closing the target document while drafting prevents creation", async () => { + const { app, document } = context(); + await new CreateFabricationPart().execute(app); + app.documents.delete(document); + expect((await submit()).error).toBe("fabrication.documentClosed"); + expect(build).not.toHaveBeenCalled(); + expect(app.newDocument).not.toHaveBeenCalled(); +}); + +test("editing requires exactly one mechanical part, including nonvisual selections", async () => { + const { app, document } = context(); + const toast = rs.spyOn(PubSub.default, "pub"); + const part = new FabricationPartNode({ document, initialShape: new MockShape() }); + document.modelManager.addNode(part); + document.selection.getSelectedNodes = () => [part, document.modelManager.rootNode]; + await new EditFabricationPart().execute(app); + expect(show).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith("showToast", "fabrication.selectPart"); +}); + +test("edit failure is returned to the open draft; an unchanged valid edit succeeds", async () => { + const { app, document } = context(); + const part = new FabricationPartNode({ document, initialShape: new MockShape() }); + document.modelManager.addNode(part); + document.selection.getSelectedNodes = () => [part]; + const apply = rs.spyOn(part, "applyParameters").mockReturnValueOnce(Result.err("Invalid geometry")); + await new EditFabricationPart().execute(app); + expect(show.mock.calls[0][1]).toBe("edit"); + expect((await submit()).error).toBe("Invalid geometry"); + apply.mockReturnValueOnce(Result.ok(false)); + expect((await submit()).value).toBe(true); + app.documents.delete(document); + expect((await submit()).error).toBe("fabrication.documentClosed"); + expect(apply).toHaveBeenCalledTimes(2); +}); diff --git a/packages/app/test/commands/fabricationDialog.test.ts b/packages/app/test/commands/fabricationDialog.test.ts new file mode 100644 index 0000000..3a46dfd --- /dev/null +++ b/packages/app/test/commands/fabricationDialog.test.ts @@ -0,0 +1,141 @@ +// Part of the Chili3d Project, under the AGPL-3.0 License. +// See LICENSE file in the project root for full license information. + +import { Result } from "@chili3d/core"; +import { afterEach, beforeEach, expect, rs, test } from "@rstest/core"; +import { + defaultFabricationParameters, + type FabricationParameters, +} from "../../src/bodys/fabricationParameters"; +import { showFabricationDialog } from "../../src/commands/fabricationDialog"; + +function dialog(): HTMLDialogElement { + return document.querySelector("dialog[data-fabrication-dialog]")!; +} + +function input(name: string): HTMLInputElement { + return dialog().querySelector(`input[name="${name}"]`)!; +} + +function setNumber(name: string, value: string): void { + input(name).value = value; + input(name).dispatchEvent(new Event("input", { bubbles: true })); +} + +function submit(): void { + dialog() + .querySelector("form")! + .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); +} + +beforeEach(() => { + rs.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + queueMicrotask(() => callback(0)); + return 1; + }); +}); + +afterEach(() => { + for (const element of document.querySelectorAll("dialog")) element.close(); + document.body.replaceChildren(); + rs.unstubAllGlobals(); +}); + +test("draft validation prevents submission, then correction submits and restores focus", async () => { + const trigger = document.createElement("button"); + document.body.append(trigger); + trigger.focus(); + const callback = rs.fn(async () => Result.ok(true)); + const closed = showFabricationDialog(defaultFabricationParameters(), "create", callback); + expect(document.activeElement).toBe(dialog().querySelector("select")); + expect(dialog().textContent).toContain("fabrication.mm"); + setNumber("width", "10"); + expect(callback).not.toHaveBeenCalled(); + submit(); + expect(callback).not.toHaveBeenCalled(); + expect(dialog().open).toBe(true); + expect(dialog().querySelector('[role="alert"]')?.textContent).toContain("clearance"); + setNumber("width", "120"); + submit(); + await closed; + expect(callback).toHaveBeenCalledExactlyOnceWith({ ...defaultFabricationParameters(), width: 120 }); + expect(document.querySelector("dialog")).toBeNull(); + expect(document.activeElement).toBe(trigger); +}); + +test("geometry failure retains the draft and permits a successful retry", async () => { + const callback = rs.fn(async (): Promise> => Result.err("Kernel refused this part")); + const closed = showFabricationDialog(defaultFabricationParameters(), "edit", callback); + setNumber("length", "90"); + submit(); + await rs.waitFor(() => + expect(dialog().querySelector('[role="alert"]')?.textContent).toBe("Kernel refused this part"), + ); + expect(input("length").value).toBe("90"); + expect(input("length").disabled).toBe(false); + expect(input("vertical.rows").disabled).toBe(true); + expect(document.activeElement).toBe(dialog().querySelector('[role="alert"]')); + callback.mockResolvedValueOnce(Result.ok(true)); + submit(); + await closed; + expect(callback).toHaveBeenCalledTimes(2); +}); + +test("busy submission blocks duplicate work and Escape cancellation until it finishes", async () => { + let finish!: (result: Result) => void; + const callback = rs.fn( + () => + new Promise>((resolve) => { + finish = resolve; + }), + ); + const closed = showFabricationDialog(defaultFabricationParameters(), "create", callback); + submit(); + await rs.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + expect(input("width").disabled).toBe(true); + expect(dialog().querySelector("form")?.getAttribute("aria-busy")).toBe("true"); + submit(); + const cancel = new Event("cancel", { cancelable: true }); + dialog().dispatchEvent(cancel); + expect(cancel.defaultPrevented).toBe(true); + expect(callback).toHaveBeenCalledTimes(1); + finish(Result.ok(true)); + await closed; +}); + +test("bracket and hole toggles update the drawing and ignore inactive invalid values", async () => { + const callback = rs.fn(async (_parameters: FabricationParameters) => Result.ok(true)); + const closed = showFabricationDialog(defaultFabricationParameters(), "create", callback); + expect(dialog().querySelectorAll('svg[role="img"]')).toHaveLength(1); + const kind = dialog().querySelector("select")!; + kind.value = "bracket"; + kind.dispatchEvent(new Event("change", { bubbles: true })); + expect(input("height").disabled).toBe(false); + expect(input("vertical.rows").disabled).toBe(false); + expect(dialog().querySelectorAll('svg[role="img"]')).toHaveLength(2); + setNumber("vertical.rows", ""); + input("vertical.enabled").click(); + expect(input("vertical.rows").disabled).toBe(true); + expect(dialog().querySelector('[role="alert"]')?.textContent).toBe(""); + submit(); + await closed; + expect(callback.mock.calls[0][0].vertical.enabled).toBe(false); + expect(callback.mock.calls[0][0].kind).toBe("bracket"); +}); + +test("cancel discards edits and document shortcuts do not receive input keys", async () => { + const callback = rs.fn(async () => Result.ok(true)); + const shortcut = rs.fn(); + document.addEventListener("keydown", shortcut); + try { + const closed = showFabricationDialog(defaultFabricationParameters(), "create", callback); + setNumber("width", "120"); + input("width").dispatchEvent(new KeyboardEvent("keydown", { key: "Delete", bubbles: true })); + expect(shortcut).not.toHaveBeenCalled(); + dialog().querySelector('button[type="button"]')!.click(); + await closed; + expect(callback).not.toHaveBeenCalled(); + } finally { + document.removeEventListener("keydown", shortcut); + } +}); diff --git a/packages/builder/src/ribbon.ts b/packages/builder/src/ribbon.ts index 178e0bd..acf73f1 100644 --- a/packages/builder/src/ribbon.ts +++ b/packages/builder/src/ribbon.ts @@ -7,6 +7,10 @@ export const DefaultRibbon: RibbonTabProfile[] = [ { tabName: "ribbon.tab.model", groups: [ + { + groupName: "ribbon.group.fabrication", + items: ["fabrication.create", "fabrication.edit"], + }, { groupName: "ribbon.group.draw", items: [ diff --git a/packages/core/src/i18n/keys.ts b/packages/core/src/i18n/keys.ts index 94bf74d..2e3e124 100644 --- a/packages/core/src/i18n/keys.ts +++ b/packages/core/src/i18n/keys.ts @@ -53,6 +53,7 @@ export const I18N_KEYS = [ "body.cylinder", "body.editableShape", "body.ellipse", + "body.fabricationPart", "body.face", "body.helix", "body.fuse", @@ -157,6 +158,8 @@ export const I18N_KEYS = [ "command.doc.saveToFile", "command.edit.redo", "command.edit.undo", + "command.fabrication.create", + "command.fabrication.edit", "command.file.export", "command.file.import", "command.measure.angle", @@ -272,6 +275,36 @@ export const I18N_KEYS = [ "error.input.threeNumberCanBeInput", "error.input.unsupportedInputs", "error.select.twoEdges", + "fabrication.apply", + "fabrication.bracket", + "fabrication.building", + "fabrication.columns", + "fabrication.count", + "fabrication.create", + "fabrication.created", + "fabrication.description", + "fabrication.diameter", + "fabrication.dimensions", + "fabrication.documentClosed", + "fabrication.documentName", + "fabrication.enableHoles", + "fabrication.failed", + "fabrication.height", + "fabrication.horizontal", + "fabrication.kind", + "fabrication.layoutNote", + "fabrication.length", + "fabrication.mm", + "fabrication.pitchX", + "fabrication.pitchY", + "fabrication.plate", + "fabrication.preview", + "fabrication.previewInvalid", + "fabrication.rows", + "fabrication.selectPart", + "fabrication.thickness", + "fabrication.vertical", + "fabrication.width", "file.format", "helix.pitch", "home.recent", @@ -413,6 +446,7 @@ export const I18N_KEYS = [ "ribbon.group.converter", "ribbon.group.dimension", "ribbon.group.draw", + "ribbon.group.fabrication", "ribbon.group.feature", "ribbon.group.group", "ribbon.group.importExport", diff --git a/packages/i18n/src/en.ts b/packages/i18n/src/en.ts index e32f54c..f113c5b 100644 --- a/packages/i18n/src/en.ts +++ b/packages/i18n/src/en.ts @@ -62,6 +62,7 @@ export default { "body.cylinder": "Cylinder", "body.editableShape": "Editable Shape", "body.ellipse": "Ellipse", + "body.fabricationPart": "Mechanical part", "body.face": "Face", "body.helix": "Helix", "body.fuse": "Fuse", @@ -166,6 +167,8 @@ export default { "command.doc.saveToFile": "Save To File", "command.edit.redo": "Redo", "command.edit.undo": "Undo", + "command.fabrication.create": "Create mechanical part", + "command.fabrication.edit": "Edit mechanical part", "command.file.export": "Export", "command.file.import": "Import", "command.measure.angle": "Angle", @@ -281,6 +284,39 @@ export default { "error.input.threeNumberCanBeInput": "Reference point is empty, only 3 numbers can be entered", "error.input.unsupportedInputs": "Exceeds the maximum number of inputs", "error.select.twoEdges": "Please select two adjacent edges", + "fabrication.apply": "Apply changes", + "fabrication.bracket": "L-bracket", + "fabrication.building": "Building part…", + "fabrication.columns": "Columns", + "fabrication.count": "count", + "fabrication.create": "Create part", + "fabrication.created": "Mechanical part created.", + "fabrication.description": + "Configure a plate or L-bracket with centered hole grids. All dimensions are in millimetres.", + "fabrication.diameter": "Hole diameter", + "fabrication.dimensions": "Stock dimensions", + "fabrication.documentClosed": "This document is no longer open. Reopen it before editing.", + "fabrication.documentName": "Mechanical part", + "fabrication.enableHoles": "Enable holes", + "fabrication.failed": + "The part could not be built. Check the dimensions and hole layout, then try again.", + "fabrication.height": "Height", + "fabrication.horizontal": "Horizontal face", + "fabrication.kind": "Part type", + "fabrication.layoutNote": + "2D layout only. Maximum: 32 holes per part and 2000 mm per dimension. Minimum thickness: 0.5 mm. Keep at least 0.5 mm of material between holes and from holes to stock edges or the adjoining flange.", + "fabrication.length": "Length", + "fabrication.mm": "mm", + "fabrication.pitchX": "Column spacing (centre to centre)", + "fabrication.pitchY": "Row spacing (centre to centre)", + "fabrication.plate": "Plate", + "fabrication.preview": "Hole layout preview", + "fabrication.previewInvalid": "Enter valid dimensions to see the layout.", + "fabrication.rows": "Rows", + "fabrication.selectPart": "Select exactly one mechanical part to edit.", + "fabrication.thickness": "Thickness", + "fabrication.vertical": "Vertical face", + "fabrication.width": "Width", "file.format": "Format", "helix.pitch": "Pitch", "home.recent": "Recent Projects", @@ -423,6 +459,7 @@ export default { "ribbon.group.converter": "Converter", "ribbon.group.dimension": "Dimension", "ribbon.group.draw": "Sketch", + "ribbon.group.fabrication": "Mechanical parts", "ribbon.group.feature": "Feature", "ribbon.group.group": "Group", "ribbon.group.importExport": "Import/Export", diff --git a/packages/i18n/src/pt-br.ts b/packages/i18n/src/pt-br.ts index 966ce7d..bf9c33b 100644 --- a/packages/i18n/src/pt-br.ts +++ b/packages/i18n/src/pt-br.ts @@ -60,6 +60,7 @@ export default { "body.box": "Caixa", "body.cylinder": "Cilindro", "body.editableShape": "Forma Editável", + "body.fabricationPart": "Peça mecânica", "body.helix": "Hélice", "body.meshNode": "Nó Mesh", "body.multiShape": "Multi Formas", @@ -70,6 +71,7 @@ export default { "features.suppress": "Suprimir", "features.unsuppress": "Remover supressão", "prompt.select.sketch": "Selecione um esboço", + "ribbon.group.fabrication": "Peças mecânicas", "ribbon.group.feature": "Recurso", "body.pyramid": "Pirâmide", "body.rect": "Retângulo", @@ -188,8 +190,43 @@ export default { "option.command.externalRole.reference": "Referência", "command.edit.redo": "Refazer", "command.edit.undo": "Desfazer", + "command.fabrication.create": "Criar peça mecânica", + "command.fabrication.edit": "Editar peça mecânica", "command.file.export": "Exportar", "command.file.import": "Importar", + "fabrication.apply": "Aplicar alterações", + "fabrication.bracket": "Suporte em L", + "fabrication.building": "Gerando peça…", + "fabrication.columns": "Colunas", + "fabrication.count": "quantidade", + "fabrication.create": "Criar peça", + "fabrication.created": "Peça mecânica criada.", + "fabrication.description": + "Configure uma placa ou um suporte em L com grades de furos centralizadas. Todas as dimensões estão em milímetros.", + "fabrication.diameter": "Diâmetro dos furos", + "fabrication.dimensions": "Dimensões do material", + "fabrication.documentClosed": "Este documento não está mais aberto. Reabra-o antes de editar.", + "fabrication.documentName": "Peça mecânica", + "fabrication.enableHoles": "Ativar furos", + "fabrication.failed": + "Não foi possível gerar a peça. Verifique as dimensões e a disposição dos furos e tente novamente.", + "fabrication.height": "Altura", + "fabrication.horizontal": "Face horizontal", + "fabrication.kind": "Tipo de peça", + "fabrication.layoutNote": + "Somente disposição 2D. Máximo de 32 furos por peça e 2000 mm por dimensão. Espessura mínima: 0,5 mm. Mantenha pelo menos 0,5 mm de material entre furos e dos furos às bordas do material ou à aba adjacente.", + "fabrication.length": "Comprimento", + "fabrication.mm": "mm", + "fabrication.pitchX": "Espaçamento entre colunas (centro a centro)", + "fabrication.pitchY": "Espaçamento entre linhas (centro a centro)", + "fabrication.plate": "Placa", + "fabrication.preview": "Prévia da disposição dos furos", + "fabrication.previewInvalid": "Insira dimensões válidas para ver a disposição.", + "fabrication.rows": "Linhas", + "fabrication.selectPart": "Selecione exatamente uma peça mecânica para editar.", + "fabrication.thickness": "Espessura", + "fabrication.vertical": "Face vertical", + "fabrication.width": "Largura", "home.recent": "Projetos recentes", "home.recent.empty": "Nenhum documento recente. Crie ou abra um documento para começar.", "home.videos": "Vídeos da comunidade", diff --git a/packages/i18n/src/ru.ts b/packages/i18n/src/ru.ts index 07bb4d3..015d9ab 100644 --- a/packages/i18n/src/ru.ts +++ b/packages/i18n/src/ru.ts @@ -22,6 +22,7 @@ export default { "body.cylinder": "Цилиндр", "body.editableShape": "Редактируемая Фигур", "body.ellipse": "Элипс", + "body.fabricationPart": "Механическая деталь", "body.face": "Сторона", "body.helix": "Спираль", "body.fuse": "Предохранитель", @@ -125,6 +126,8 @@ export default { "command.doc.saveToFile": "Сохранить в файл", "command.edit.redo": "Вернуть", "command.edit.undo": "Откатить", + "command.fabrication.create": "Создать механическую деталь", + "command.fabrication.edit": "Редактировать механическую деталь", "command.file.export": "Экспорт", "command.file.import": "Импорт", "command.measure.angle": "Угол", @@ -238,6 +241,40 @@ export default { "error.input.threeNumberCanBeInput": "Не заполнена котрольная точка, нужно только 3 номера", "error.input.unsupportedInputs": "Достигнут предел вводимых номеров", "error.select.twoEdges": "Пожалуйста, выберите две смежные грани", + "fabrication.apply": "Применить изменения", + "fabrication.bracket": "Г-образный кронштейн", + "fabrication.building": "Построение детали…", + "fabrication.columns": "Столбцы", + "fabrication.count": "количество", + "fabrication.create": "Создать деталь", + "fabrication.created": "Механическая деталь создана.", + "fabrication.description": + "Настройте пластину или Г-образный кронштейн с сетками отверстий по центру. Все размеры указаны в миллиметрах.", + "fabrication.diameter": "Диаметр отверстий", + "fabrication.dimensions": "Размеры заготовки", + "fabrication.documentClosed": + "Этот документ больше не открыт. Откройте его снова перед редактированием.", + "fabrication.documentName": "Механическая деталь", + "fabrication.enableHoles": "Добавить отверстия", + "fabrication.failed": + "Не удалось построить деталь. Проверьте размеры и расположение отверстий, затем повторите попытку.", + "fabrication.height": "Высота", + "fabrication.horizontal": "Горизонтальная грань", + "fabrication.kind": "Тип детали", + "fabrication.layoutNote": + "Только 2D-схема. Не более 32 отверстий на деталь; каждый размер не более 2000 мм. Минимальная толщина: 0,5 мм. Между отверстиями, а также от отверстий до края заготовки или прилегающей полки должно оставаться не менее 0,5 мм материала.", + "fabrication.length": "Длина", + "fabrication.mm": "мм", + "fabrication.pitchX": "Шаг столбцов (между центрами)", + "fabrication.pitchY": "Шаг рядов (между центрами)", + "fabrication.plate": "Пластина", + "fabrication.preview": "Предпросмотр расположения отверстий", + "fabrication.previewInvalid": "Введите допустимые размеры для просмотра схемы.", + "fabrication.rows": "Ряды", + "fabrication.selectPart": "Выберите ровно одну механическую деталь для редактирования.", + "fabrication.thickness": "Толщина", + "fabrication.vertical": "Вертикальная грань", + "fabrication.width": "Ширина", "file.format": "Формат", "helix.pitch": "Шаг", "home.recent": "Недавние проекты", @@ -362,6 +399,7 @@ export default { "ribbon.group.converter": "Конвертер", "ribbon.group.dimension": "Размер", "ribbon.group.draw": "Проект", + "ribbon.group.fabrication": "Механические детали", "ribbon.group.feature": "Элемент", "ribbon.group.group": "Группа", "ribbon.group.importExport": "Импорт/Экспорт", diff --git a/packages/i18n/src/zh-cn.ts b/packages/i18n/src/zh-cn.ts index 2589624..ee2537a 100644 --- a/packages/i18n/src/zh-cn.ts +++ b/packages/i18n/src/zh-cn.ts @@ -59,6 +59,7 @@ export default { "body.cylinder": "圆柱", "body.editableShape": "可编辑的形状", "body.ellipse": "椭圆", + "body.fabricationPart": "机械零件", "body.face": "面", "body.helix": "螺旋线", "body.fuse": "合并", @@ -163,6 +164,8 @@ export default { "command.doc.saveToFile": "保存到文件", "command.edit.redo": "重做", "command.edit.undo": "撤销", + "command.fabrication.create": "创建机械零件", + "command.fabrication.edit": "编辑机械零件", "command.file.export": "导出", "command.file.import": "导入", "command.measure.angle": "角度", @@ -278,6 +281,37 @@ export default { "error.input.threeNumberCanBeInput": "参照点为空,只能输入 3 个数", "error.input.unsupportedInputs": "超过最大输入数", "error.select.twoEdges": "请选择两条相邻的边", + "fabrication.apply": "应用更改", + "fabrication.bracket": "L 形支架", + "fabrication.building": "正在生成零件…", + "fabrication.columns": "列数", + "fabrication.count": "数量", + "fabrication.create": "创建零件", + "fabrication.created": "机械零件已创建。", + "fabrication.description": "配置平板或 L 形支架及其居中的孔阵列。所有尺寸均以毫米为单位。", + "fabrication.diameter": "孔径", + "fabrication.dimensions": "材料尺寸", + "fabrication.documentClosed": "此文档已关闭。请重新打开后再编辑。", + "fabrication.documentName": "机械零件", + "fabrication.enableHoles": "启用孔阵列", + "fabrication.failed": "无法生成零件。请检查尺寸和孔位布局后重试。", + "fabrication.height": "高度", + "fabrication.horizontal": "水平面", + "fabrication.kind": "零件类型", + "fabrication.layoutNote": + "仅显示二维孔位布局。每个零件最多 32 个孔,各尺寸不超过 2000 mm。最小厚度为 0.5 mm;孔与孔之间,以及孔到材料边缘或相邻翼板之间,至少保留 0.5 mm 的材料。", + "fabrication.length": "长度", + "fabrication.mm": "mm", + "fabrication.pitchX": "列间距(中心距)", + "fabrication.pitchY": "行间距(中心距)", + "fabrication.plate": "平板", + "fabrication.preview": "孔位布局预览", + "fabrication.previewInvalid": "请输入有效尺寸以查看布局。", + "fabrication.rows": "行数", + "fabrication.selectPart": "请选择且仅选择一个机械零件进行编辑。", + "fabrication.thickness": "厚度", + "fabrication.vertical": "垂直面", + "fabrication.width": "宽度", "file.format": "文件格式", "helix.pitch": "螺距", "home.recent": "最近项目", @@ -419,6 +453,7 @@ export default { "ribbon.group.converter": "转换", "ribbon.group.dimension": "标注", "ribbon.group.draw": "绘制", + "ribbon.group.fabrication": "机械零件", "ribbon.group.feature": "特征", "ribbon.group.group": "组", "ribbon.group.importExport": "导入/导出", diff --git a/packages/ui/src/home/home.ts b/packages/ui/src/home/home.ts index 296f473..1b3e301 100644 --- a/packages/ui/src/home/home.ts +++ b/packages/ui/src/home/home.ts @@ -47,6 +47,7 @@ export class Home extends HTMLElement { ), div({ className: style.navigationLabel, textContent: "WORKSPACE" }), this.action("New document", "icon-plus", "doc.new", true), + this.action(new Localize("command.fabrication.create"), "icon-box", "fabrication.create"), this.action("Open document", "icon-folder", "doc.open"), this.action("Open sample fixture", "icon-box", "doc.sample"), this.app.activeView?.document @@ -83,9 +84,9 @@ export class Home extends HTMLElement { } private action( - text: string, + text: string | Localize, icon: string, - command: "doc.new" | "doc.open" | "doc.sample", + command: "doc.new" | "doc.open" | "doc.sample" | "fabrication.create", primary = false, ) { return button(