diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..7f62c1d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,89 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ FLUTTER_VERSION: 3.47.2
+
+jobs:
+ quality:
+ name: Analyze and test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+ channel: stable
+ cache: true
+ - run: flutter pub get
+ - run: flutter analyze
+ - run: flutter test
+ - run: flutter test
+ working-directory: example
+
+ android:
+ name: Android debug build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+ channel: stable
+ cache: true
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 17
+ - run: flutter pub get
+ working-directory: example
+ - run: flutter build apk --debug --target-platform android-arm64
+ working-directory: example
+ - name: Verify API and ABI contract in APK
+ working-directory: example
+ run: |
+ apk=build/app/outputs/flutter-apk/app-debug.apk
+ apkanalyzer="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/apkanalyzer"
+ test -x "$apkanalyzer"
+ test "$("$apkanalyzer" manifest min-sdk "$apk")" = "29"
+ test "$(unzip -Z1 "$apk" | awk -F/ '/^lib\// {print $2}' | sort -u)" = "arm64-v8a"
+
+ macos:
+ name: macOS release build
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+ channel: stable
+ cache: true
+ - run: flutter precache --macos
+ - run: flutter pub get
+ working-directory: example
+ - run: python3 tool/macos_run.py --mode release --build-only
+ working-directory: example
+
+ platform-contract:
+ name: Platform contract
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Verify declared platform hosts
+ run: |
+ test -d example/android
+ test -d example/macos
+ test ! -d example/web
+ test ! -d example/ios
+ test ! -d example/linux
+ test ! -d example/windows
+ grep -F 'minSdk = 29' example/android/app/build.gradle.kts
+ grep -F 'abiFilters += "arm64-v8a"' example/android/app/build.gradle.kts
diff --git a/.pubignore b/.pubignore
new file mode 100644
index 0000000..609b070
--- /dev/null
+++ b/.pubignore
@@ -0,0 +1,10 @@
+.github/
+.agents/
+.codex/
+build/
+docs/
+AGENTS.md
+AI_ANALYSIS.md
+CONTEXT.md
+OWNERS.md
+PHASE_SUMMARY.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..e4d0c24
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,76 @@
+# Agent guide
+
+## Repository purpose
+
+`gcode_core` parses a deliberately small G-code subset, builds two-dimensional
+toolpaths, and renders them through Flutter GPU. The package is GPU-only; do not
+silently introduce a second renderer or claim a platform is supported from a
+successful cross-compile alone.
+
+## Layout and ownership
+
+- `lib/src/parser`, `models`, `services`: platform-neutral parsing and geometry.
+- `lib/src/data/readers`: native file-system readers; currently uses `dart:io`.
+- `lib/src/rendering`: Flutter GPU resources, geometry, surfaces, and shaders.
+- `lib/src/widgets`: reusable package UI.
+- `shaders`: source shaders plus the checked-in generated shader bundle.
+- `example/lib/src/gcode_session_controller.dart`: example state and playback.
+- `example/lib/src/gcode_example_page.dart`: page composition only.
+- `example/lib/src/widgets`: independently testable example UI.
+- `example/lib/gpu_validation.dart`: native GPU lifecycle/performance harness.
+- `docs/evidence`: durable runtime evidence; do not rewrite historical evidence.
+
+## Current platform contract
+
+- macOS: primary validated GPU platform.
+- Android: API 29+ and ARM64-only. The host and APK build are present; runtime
+ GPU/device evidence is pending. Do not add ARM32 or x86 compatibility without
+ an explicit product decision.
+- iOS, Linux, Windows: not supported until hosts, builds, and native evidence land.
+- Web: unsupported while `dart:io` and the GPU-only renderer remain unconditional.
+
+Update the platform-contract CI job and this section together when adding a
+host. A build is only build evidence. Runtime support requires a platform report
+with device/OS, Flutter revision, artifact revision, file-picker behavior, GPU
+initialization, screenshots, and frame/memory measurements.
+
+## Change boundaries
+
+- Keep parsing behavior out of widgets.
+- Keep file-picker calls in the example controller or a platform service.
+- Treat segment lists as immutable; replace the list when geometry changes.
+- Reuse GPU buffers and surfaces across frames. Dispose `ui.Image` handles.
+- Do not rebuild geometry for playback-only progress changes.
+- Preserve unrelated evidence and generated platform files.
+
+## Test growth order
+
+1. Pure unit tests for parser, bounds, builders, and viewport math.
+2. Controller tests for loading, playback, replay, seeking, and disposal.
+3. Widget tests at 320, 600, 720, and desktop widths.
+4. Android ARM64 API 29 and API 35 integration tests for picker cancellation
+ and sample load. Do not use x86/x86_64 emulator evidence.
+5. Native GPU profile runs using `gpu_validation.dart`.
+6. Add iOS/Windows/Linux build jobs only with their corresponding host changes.
+
+Minimum local gate from the repository root:
+
+```sh
+flutter analyze
+flutter test
+(cd example && flutter test)
+```
+
+Run Android builds from `example`, not the package root:
+
+```sh
+cd example
+flutter build apk --debug --target-platform android-arm64
+```
+
+macOS uses its checked compatibility entrypoint:
+
+```sh
+cd example
+python3 tool/macos_run.py --mode release --build-only
+```
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fcada0a..49d5664 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## 0.2.0 — 2026-09-23
+
+First stable package release. It promotes the Flutter GPU renderer from the
+macOS prerelease and adds the Android API 29+ ARM64 host contract, adaptive
+example UI, session-controller extraction, and CI coverage for package tests,
+Android builds, macOS builds, and declared platform support.
+
+### Platform support
+
+- macOS: Flutter GPU runtime baseline validated.
+- Android: API 29+ ARM64 host, build, and physical-device runtime validated.
+- Web, Windows, Linux, and iOS: not supported by the GPU renderer in this
+ release.
+
## 0.2.0-dev.1 — 2026-09-06
First macOS prerelease, distributed by Git tag / GitHub Release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..cc3c17c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 lizy-coding
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index b482ba7..3b5f1c4 100644
--- a/README.md
+++ b/README.md
@@ -2,23 +2,37 @@

-G-code parsing and visualization package extracted for Flutter Forge.
+G-code parsing, streaming toolpath construction, playback UI, and Flutter GPU
+visualization for Flutter applications.
-## First macOS prerelease: 0.2.0-dev.1
+## Install
-This release is distributed through GitHub/Git, not pub.dev. Pin the release tag
-instead of following `dev`:
+Version 0.2.0 is published on pub.dev:
```yaml
dependencies:
- gcode_core:
- git:
- url: https://github.com/lizy-coding/gcode_core.git
- ref: v0.2.0-dev.1
+ gcode_core: ^0.2.0
```
-See [release notes](docs/releases/0.2.0-dev.1.md) and
-[CHANGELOG](CHANGELOG.md) for breaking changes and validation limits.
+Flutter 3.47.2 or newer is required. The package bundles its compiled shader
+asset; consumers do not need to copy shader files manually.
+
+## Platform support
+
+| Platform | Status | Requirements |
+| --- | --- | --- |
+| macOS | Supported | macOS 12+, Impeller and Flutter GPU enabled |
+| Android | Supported | API 29+, ARM64, Impeller and Flutter GPU enabled |
+| iOS | Not supported | No validated host contract in 0.2.0 |
+| Windows | Not supported | Flutter GPU renderer is not admitted in 0.2.0 |
+| Linux | Not supported | No validated host contract in 0.2.0 |
+| Web | Not supported | The renderer and file reader use native-only APIs |
+
+Unsupported platforms do not imply that parsing concepts are platform-specific;
+the published package as a whole includes a GPU-only Flutter renderer and is
+released only against the hosts listed as supported.
+
+### Host configuration
For a macOS host, use Flutter 3.47.2 and add these keys to the top-level dict in
`macos/Runner/Info.plist`:
@@ -30,11 +44,11 @@ For a macOS host, use Flutter 3.47.2 and add these keys to the top-level dict in
```
-The host needs a macOS deployment target of at least 12.0; runtime evidence is
-currently limited to macOS 26.5 on Apple Silicon. The package bundles its shader
-asset automatically. Example Xcode/CocoaPods workarounds do not propagate into
-consumer apps and should only be adopted if the same build issue occurs.
-This is a Flutter package; its public entry point is not a pure Dart CLI API.
+The macOS deployment target must be at least 12.0. On Android, use a minimum SDK
+of 29, build for `arm64-v8a`, and keep Impeller enabled. The example project is
+the reference host configuration for both platforms. Example Xcode/CocoaPods
+workarounds do not propagate into consumer apps and should only be adopted if
+the same build issue occurs.
## Scope
@@ -53,7 +67,8 @@ Flutter 3.47.2 or newer is required. `GcodeCanvas` is GPU-only: G0 dashes,
G1 lines, background paths, playback, grid, origin, tool head and glow are all
rendered by GPU shaders. There is no Canvas backend or automatic fallback.
Flutter only composites the resulting image and displays ordinary UI widgets.
-Only macOS has been exercised in this implementation phase.
+The renderer has no Canvas fallback. Unsupported GPU initialization is surfaced
+as an error so applications can provide an explicit unavailable state.
```dart
GcodeCanvas(
@@ -88,12 +103,19 @@ fields (`toolHeadColor`, `toolHeadGlowColor`, `originDotColor`), replacing Paint
objects. Unsupported GPU initialization is reported as an error, never a
fallback renderer.
-## Test
+## Validation
```bash
flutter test
+(cd example && flutter test)
```
+CI keeps separate quality, Android build, macOS build, and platform-contract
+jobs. Android is deliberately constrained to API 29+ and `arm64-v8a`. Native
+acceptance evidence remains platform-specific; adding a new host requires its
+own build, runtime, rendering, lifecycle, and performance evidence. See
+`AGENTS.md` for the admission contract.
+
## Example
Run the Flutter example app:
@@ -101,6 +123,8 @@ Run the Flutter example app:
```bash
cd example
flutter run -d macos
+# or an API 29+ ARM64 Android device
+flutter run -d
```
IDE runs use `example/lib/main.dart` with the macOS device. The example's
@@ -136,3 +160,7 @@ G1 X10 Y10
}
}
```
+
+## License
+
+MIT. See [LICENSE](LICENSE).
diff --git a/example/.metadata b/example/.metadata
index c24b9a1..f6f0896 100644
--- a/example/.metadata
+++ b/example/.metadata
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
- revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
+ revision: "d3b14c876900e553bc736ca19295fc09e3853e8e"
channel: "stable"
project_type: app
@@ -18,6 +18,9 @@ migration:
- platform: macos
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+ - platform: android
+ create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
+ base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
# User provided section
diff --git a/example/README.md b/example/README.md
index 25b6ade..4f72f2a 100644
--- a/example/README.md
+++ b/example/README.md
@@ -13,21 +13,17 @@ It demonstrates the full local workflow:
- Show commands and parse errors with `CommandTimeline`.
- Preview the generated path with `PlaybackControls`.
-The main integration points are:
+The example keeps state and platform access in `GcodeSessionController`, while
+the page only composes adaptive widgets. The main integration points are:
```dart
-final pipeline = GcodeReadlinePipeline(
- options: const GcodeReadlineOptions(snapshotBatchSize: 1),
-);
-
-await for (final snapshot in pipeline.load(FileGcodeLineReader(file.path))) {
- setState(() => _snapshot = snapshot);
-}
+final controller = GcodeSessionController();
+await controller.loadSample();
GcodeCanvas(
- segments: snapshot.segments,
- progress: playbackProgress,
- errorCount: snapshot.errors.length,
+ segments: controller.snapshot?.segments ?? const [],
+ progress: controller.playbackProgress.value,
+ errorCount: controller.snapshot?.errors.length ?? 0,
);
```
@@ -36,6 +32,19 @@ Run it from this directory:
```bash
flutter run
```
+
+Android support is intentionally limited to API 29+ on `arm64-v8a`. Build
+verification runs from this directory:
+
+```sh
+flutter build apk --debug --target-platform android-arm64
+```
+
+For Play distribution, keep the same ABI contract:
+
+```sh
+flutter build appbundle --release --target-platform android-arm64
+```
# macOS 本机构建兼容入口
若 Xcode 26.6 卡在 `clang -v -E -dM`,从本目录运行:
diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml
index 5d3e697..b3bebc0 100644
--- a/example/analysis_options.yaml
+++ b/example/analysis_options.yaml
@@ -11,6 +11,7 @@ analyzer:
exclude:
- build/**
- macos/**
+ - android/**
include: package:flutter_lints/flutter.yaml
linter:
diff --git a/example/android/.gitignore b/example/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/example/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts
new file mode 100644
index 0000000..d22d0fb
--- /dev/null
+++ b/example/android/app/build.gradle.kts
@@ -0,0 +1,55 @@
+plugins {
+ id("com.android.application")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "com.example.example"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.example.example"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ // Flutter GPU/Impeller is the product baseline; older Android versions
+ // and non-ARM64 devices are intentionally outside the support contract.
+ minSdk = 29
+ targetSdk = flutter.targetSdkVersion
+ ndk {
+ abiFilters.clear()
+ abiFilters += "arm64-v8a"
+ }
+ // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
+ // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
+ // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
+ // flag during build.
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/example/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..74a78b9
--- /dev/null
+++ b/example/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt
new file mode 100644
index 0000000..ac81bae
--- /dev/null
+++ b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt
@@ -0,0 +1,5 @@
+package com.example.example
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/example/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/example/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/example/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/example/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/example/android/build.gradle.kts b/example/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/example/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/example/android/gradle.properties b/example/android/gradle.properties
new file mode 100644
index 0000000..e96108c
--- /dev/null
+++ b/example/android/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+# This newDsl flag was added by the Flutter template
+android.newDsl=false
+# This builtInKotlin flag was added by the Flutter template
+android.builtInKotlin=false
diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a20f2c4
--- /dev/null
+++ b/example/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts
new file mode 100644
index 0000000..b28021a
--- /dev/null
+++ b/example/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "9.1.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.4.0" apply false
+}
+
+include(":app")
diff --git a/example/lib/main.dart b/example/lib/main.dart
index 1fca173..25c6b42 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -1,485 +1,7 @@
-import 'dart:async';
-
-import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
-import 'package:gcode_core/gcode_core.dart';
-
-void main() {
- runApp(const GcodeCoreExampleApp());
-}
-
-class GcodeCoreExampleApp extends StatelessWidget {
- const GcodeCoreExampleApp({super.key});
-
- @override
- Widget build(BuildContext context) {
- return MaterialApp(
- title: 'G-code Core Example',
- debugShowCheckedModeBanner: false,
- theme: ThemeData(
- colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff2563eb)),
- useMaterial3: true,
- ),
- home: const GcodeExamplePage(),
- );
- }
-}
-
-class GcodeExamplePage extends StatefulWidget {
- const GcodeExamplePage({super.key});
-
- @override
- State createState() => _GcodeExamplePageState();
-}
-
-class _GcodeExamplePageState extends State {
- static const _sampleSource = '''
-G0 X0 Y0
-G1 X30 Y0 F1200
-G1 X30 Y18
-G1 X12 Y18
-G0 X6 Y8
-G1 X22 Y8
-G2 X40 Y40
-''';
-
- final _pipeline = GcodeReadlinePipeline(
- options: const GcodeReadlineOptions(snapshotBatchSize: 1),
- );
-
- GcodeLoadSnapshot? _snapshot;
- String _sourceName = '未选择文件';
- String _status = '请选择本地 G-code 文件,或加载内置示例。';
- bool _loading = false;
- bool _isPlaying = false;
- double _playbackProgress = 1;
- double _speedMultiplier = 1;
- Timer? _playbackTimer;
-
- @override
- void dispose() {
- _playbackTimer?.cancel();
- super.dispose();
- }
-
- Future _pickAndParseFile() async {
- const typeGroup = XTypeGroup(
- label: 'G-code',
- extensions: ['gcode', 'nc', 'tap', 'txt'],
- );
-
- final file = await openFile(acceptedTypeGroups: [typeGroup]);
- if (file == null) return;
-
- await _parseReader(FileGcodeLineReader(file.path), sourceName: file.name);
- }
-
- Future _loadSample() {
- return _parseReader(
- const StringGcodeLineReader(_sampleSource),
- sourceName: '内置示例',
- );
- }
-
- Future _parseReader(
- GcodeLineReader reader, {
- required String sourceName,
- }) async {
- _playbackTimer?.cancel();
- setState(() {
- _loading = true;
- _isPlaying = false;
- _playbackProgress = 1;
- _sourceName = sourceName;
- _snapshot = null;
- _status = '正在读取 $sourceName';
- });
-
- await for (final snapshot in _pipeline.load(reader)) {
- if (!mounted) return;
- setState(() {
- _snapshot = snapshot;
- _status = snapshot.message;
- _playbackProgress = 1;
- });
- if (snapshot.stage == GcodeLoadStage.parsing) {
- await Future.delayed(const Duration(milliseconds: 16));
- }
- }
-
- if (!mounted) return;
- setState(() => _loading = false);
- }
-
- void _play() {
- if ((_snapshot?.segments.isEmpty ?? true) || _loading) return;
-
- _playbackTimer?.cancel();
- setState(() => _isPlaying = true);
- _playbackTimer = Timer.periodic(const Duration(milliseconds: 16), (_) {
- if (!mounted) return;
- final next = _playbackProgress + 0.004 * _speedMultiplier;
- setState(() {
- _playbackProgress = next.clamp(0, 1);
- _isPlaying = _playbackProgress < 1;
- });
- if (_playbackProgress >= 1) {
- _playbackTimer?.cancel();
- }
- });
- }
-
- void _pause() {
- _playbackTimer?.cancel();
- setState(() => _isPlaying = false);
- }
-
- void _resetPlayback() {
- _playbackTimer?.cancel();
- setState(() {
- _isPlaying = false;
- _playbackProgress = 0;
- });
- }
-
- void _seekPlayback(double value) {
- setState(() => _playbackProgress = value);
- }
-
- void _setSpeed(double value) {
- setState(() => _speedMultiplier = value);
- }
-
- int _currentCommandIndex(GcodeLoadSnapshot? snapshot) {
- final commandCount = snapshot?.commands.length ?? 0;
- if (commandCount == 0) return -1;
- return (_playbackProgress * commandCount).ceil().clamp(1, commandCount) - 1;
- }
-
- @override
- Widget build(BuildContext context) {
- final snapshot = _snapshot;
-
- return Scaffold(
- appBar: AppBar(
- title: const Text('G-code Core 绘制示例'),
- actions: [
- TextButton.icon(
- onPressed: _loading ? null : _loadSample,
- icon: const Icon(Icons.data_object),
- label: const Text('示例数据'),
- ),
- const SizedBox(width: 8),
- FilledButton.icon(
- onPressed: _loading ? null : _pickAndParseFile,
- icon: const Icon(Icons.folder_open),
- label: const Text('选择 G-code'),
- ),
- const SizedBox(width: 16),
- ],
- ),
- body: Padding(
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- _StatusBar(
- sourceName: _sourceName,
- status: _status,
- loading: _loading,
- ),
- const SizedBox(height: 16),
- Expanded(
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Expanded(
- flex: 3,
- child: _CanvasPanel(
- snapshot: snapshot,
- parsing: _loading,
- progress: _playbackProgress,
- isPlaying: _isPlaying,
- speedMultiplier: _speedMultiplier,
- onPlay: _play,
- onPause: _pause,
- onReset: _resetPlayback,
- onSeek: _seekPlayback,
- onSpeedChange: _setSpeed,
- ),
- ),
- const SizedBox(width: 16),
- SizedBox(
- width: 360,
- child: _ResultPanel(
- snapshot: snapshot,
- currentIndex: _currentCommandIndex(snapshot),
- onCommandTap: (index) {
- final total = snapshot?.commands.length ?? 0;
- if (total == 0) return;
- _pause();
- setState(() => _playbackProgress = (index + 1) / total);
- },
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
-}
-
-class _StatusBar extends StatelessWidget {
- const _StatusBar({
- required this.sourceName,
- required this.status,
- required this.loading,
- });
-
- final String sourceName;
- final String status;
- final bool loading;
-
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
-
- return DecoratedBox(
- decoration: BoxDecoration(
- border: Border.all(color: theme.colorScheme.outlineVariant),
- borderRadius: BorderRadius.circular(8),
- ),
- child: Padding(
- padding: const EdgeInsets.all(12),
- child: Row(
- children: [
- if (loading)
- const SizedBox.square(
- dimension: 18,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- else
- const Icon(Icons.route),
- const SizedBox(width: 12),
- Expanded(
- child: Text(
- '$sourceName - $status',
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- ),
- ),
- ],
- ),
- ),
- );
- }
-}
-
-class _CanvasPanel extends StatelessWidget {
- const _CanvasPanel({
- required this.snapshot,
- required this.parsing,
- required this.progress,
- required this.isPlaying,
- required this.speedMultiplier,
- required this.onPlay,
- required this.onPause,
- required this.onReset,
- required this.onSeek,
- required this.onSpeedChange,
- });
-
- final GcodeLoadSnapshot? snapshot;
- final bool parsing;
- final double progress;
- final bool isPlaying;
- final double speedMultiplier;
- final VoidCallback onPlay;
- final VoidCallback onPause;
- final VoidCallback onReset;
- final ValueChanged onSeek;
- final ValueChanged onSpeedChange;
-
- @override
- Widget build(BuildContext context) {
- final segments = snapshot?.segments ?? const [];
- final errors = snapshot?.errors.length ?? 0;
-
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Expanded(
- child: Stack(
- children: [
- Positioned.fill(
- child: GcodeCanvas(
- segments: segments,
- progress: parsing ? 1 : progress,
- errorCount: errors,
- bounds: snapshot?.bounds,
- ),
- ),
- Positioned(
- left: 12,
- top: 12,
- child: _CanvasLegend(
- parsing: parsing,
- segments: segments.length,
- mainSegments: segments
- .where(
- (segment) => segment.type == GcodeSegmentType.linear,
- )
- .length,
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 12),
- PlaybackControls(
- isPlaying: isPlaying,
- progress: parsing ? 1 : progress,
- speedMultiplier: speedMultiplier,
- onPlay: onPlay,
- onPause: onPause,
- onReset: onReset,
- onSeek: onSeek,
- onSpeedChange: onSpeedChange,
- ),
- ],
- );
- }
-}
-
-class _CanvasLegend extends StatelessWidget {
- const _CanvasLegend({
- required this.parsing,
- required this.segments,
- required this.mainSegments,
- });
-
- final bool parsing;
- final int segments;
- final int mainSegments;
-
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
-
- return DecoratedBox(
- decoration: BoxDecoration(
- color: theme.colorScheme.surface.withValues(alpha: 0.9),
- border: Border.all(color: theme.colorScheme.outlineVariant),
- borderRadius: BorderRadius.circular(8),
- ),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
- child: DefaultTextStyle(
- style: theme.textTheme.labelMedium!,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(parsing ? '动态解析绘制中' : 'GPU 轨迹绘制'),
- const SizedBox(height: 4),
- Text('主线段 G1: $mainSegments'),
- Text('移动段 G0/G1: $segments'),
- ],
- ),
- ),
- ),
- );
- }
-}
-
-class _ResultPanel extends StatelessWidget {
- const _ResultPanel({
- required this.snapshot,
- required this.currentIndex,
- required this.onCommandTap,
- });
-
- final GcodeLoadSnapshot? snapshot;
- final int currentIndex;
- final ValueChanged onCommandTap;
-
- @override
- Widget build(BuildContext context) {
- final current = snapshot;
-
- if (current == null) {
- return const Center(child: Text('解析结果会显示在这里'));
- }
-
- return ListView(
- children: [
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- _Metric(label: '行数', value: current.linesRead.toString()),
- _Metric(label: '指令', value: current.commands.length.toString()),
- _Metric(label: '轨迹', value: current.segments.length.toString()),
- _Metric(label: '错误', value: current.errors.length.toString()),
- ],
- ),
- const SizedBox(height: 16),
- CommandTimeline(
- commands: current.commands,
- errors: current.errors,
- currentIndex: currentIndex,
- onTap: onCommandTap,
- maxHeight: 360,
- ),
- const SizedBox(height: 16),
- Text('解析错误', style: Theme.of(context).textTheme.titleMedium),
- const SizedBox(height: 8),
- if (current.errors.isEmpty)
- const Text('无')
- else
- for (final error in current.errors)
- ListTile(
- dense: true,
- leading: const Icon(Icons.warning_amber),
- title: Text('第 ${error.lineNumber} 行'),
- subtitle: Text('${error.message}\n${error.rawLine}'),
- ),
- ],
- );
- }
-}
-
-class _Metric extends StatelessWidget {
- const _Metric({required this.label, required this.value});
- final String label;
- final String value;
+import 'src/app.dart';
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
+export 'src/app.dart';
- return SizedBox(
- width: 78,
- child: DecoratedBox(
- decoration: BoxDecoration(
- color: theme.colorScheme.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Padding(
- padding: const EdgeInsets.all(10),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(label, style: theme.textTheme.labelMedium),
- const SizedBox(height: 4),
- Text(value, style: theme.textTheme.titleLarge),
- ],
- ),
- ),
- ),
- );
- }
-}
+void main() => runApp(const GcodeCoreExampleApp());
diff --git a/example/lib/src/app.dart b/example/lib/src/app.dart
new file mode 100644
index 0000000..97db949
--- /dev/null
+++ b/example/lib/src/app.dart
@@ -0,0 +1,18 @@
+import 'package:flutter/material.dart';
+
+import 'gcode_example_page.dart';
+
+class GcodeCoreExampleApp extends StatelessWidget {
+ const GcodeCoreExampleApp({super.key});
+
+ @override
+ Widget build(BuildContext context) => MaterialApp(
+ title: 'G-code Core Example',
+ debugShowCheckedModeBanner: false,
+ theme: ThemeData(
+ colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff2563eb)),
+ useMaterial3: true,
+ ),
+ home: const GcodeExamplePage(),
+ );
+}
diff --git a/example/lib/src/gcode_example_page.dart b/example/lib/src/gcode_example_page.dart
new file mode 100644
index 0000000..ee15552
--- /dev/null
+++ b/example/lib/src/gcode_example_page.dart
@@ -0,0 +1,143 @@
+import 'package:flutter/material.dart';
+
+import 'gcode_session_controller.dart';
+import 'widgets/gcode_canvas_panel.dart';
+import 'widgets/gcode_result_panel.dart';
+import 'widgets/gcode_status_bar.dart';
+
+class GcodeExamplePage extends StatefulWidget {
+ const GcodeExamplePage({super.key, this.controller});
+
+ final GcodeSessionController? controller;
+
+ @override
+ State createState() => _GcodeExamplePageState();
+}
+
+class _GcodeExamplePageState extends State {
+ late final GcodeSessionController _controller =
+ widget.controller ?? GcodeSessionController();
+ late final bool _ownsController = widget.controller == null;
+
+ @override
+ void dispose() {
+ if (_ownsController) _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ListenableBuilder(
+ listenable: _controller,
+ builder: (context, _) {
+ final compact = MediaQuery.sizeOf(context).width < 600;
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(compact ? 'G-code' : 'G-code Core 绘制示例'),
+ actions: _buildActions(compact),
+ ),
+ body: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ GcodeStatusBar(
+ sourceName: _controller.sourceName,
+ status: _controller.status,
+ loading: _controller.loading,
+ ),
+ const SizedBox(height: 16),
+ Expanded(child: _buildWorkspace()),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ List _buildActions(bool compact) {
+ final sample = _controller.loading ? null : _controller.loadSample;
+ final pick = _controller.loading ? null : _controller.pickAndParseFile;
+ if (compact) {
+ return [
+ IconButton(
+ onPressed: sample,
+ icon: const Icon(Icons.data_object),
+ tooltip: '示例数据',
+ ),
+ IconButton(
+ onPressed: pick,
+ icon: const Icon(Icons.folder_open),
+ tooltip: '选择 G-code',
+ ),
+ const SizedBox(width: 8),
+ ];
+ }
+ return [
+ TextButton.icon(
+ onPressed: sample,
+ icon: const Icon(Icons.data_object),
+ label: const Text('示例数据'),
+ ),
+ const SizedBox(width: 8),
+ FilledButton.icon(
+ onPressed: pick,
+ icon: const Icon(Icons.folder_open),
+ label: const Text('选择 G-code'),
+ ),
+ const SizedBox(width: 16),
+ ];
+ }
+
+ Widget _buildWorkspace() {
+ final canvas = ValueListenableBuilder(
+ valueListenable: _controller.playbackProgress,
+ builder: (context, progress, _) => GcodeCanvasPanel(
+ snapshot: _controller.snapshot,
+ parsing: _controller.loading,
+ progress: progress,
+ isPlaying: _controller.isPlaying,
+ speedMultiplier: _controller.speedMultiplier,
+ onPlay: _controller.play,
+ onPause: _controller.pause,
+ onReset: _controller.resetPlayback,
+ onSeek: _controller.seekPlayback,
+ onSpeedChange: _controller.setSpeed,
+ ),
+ );
+ final results = ValueListenableBuilder(
+ valueListenable: _controller.currentCommandIndex,
+ builder: (context, index, _) => GcodeResultPanel(
+ snapshot: _controller.snapshot,
+ currentIndex: index,
+ onCommandTap: _controller.selectCommand,
+ ),
+ );
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ if (constraints.maxWidth >= 720) {
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Expanded(flex: 3, child: canvas),
+ const SizedBox(width: 16),
+ SizedBox(width: 360, child: results),
+ ],
+ );
+ }
+ final canvasHeight = (constraints.maxHeight * 0.58)
+ .clamp(280.0, 420.0)
+ .toDouble();
+ return ListView(
+ children: [
+ SizedBox(height: canvasHeight, child: canvas),
+ const SizedBox(height: 16),
+ SizedBox(height: 420, child: results),
+ ],
+ );
+ },
+ );
+ }
+}
diff --git a/example/lib/src/gcode_session_controller.dart b/example/lib/src/gcode_session_controller.dart
new file mode 100644
index 0000000..83e7ce8
--- /dev/null
+++ b/example/lib/src/gcode_session_controller.dart
@@ -0,0 +1,150 @@
+import 'dart:async';
+
+import 'package:file_selector/file_selector.dart';
+import 'package:flutter/foundation.dart';
+import 'package:gcode_core/gcode_core.dart';
+
+class GcodeSessionController extends ChangeNotifier {
+ GcodeSessionController({GcodeReadlinePipeline? pipeline})
+ : _pipeline =
+ pipeline ??
+ GcodeReadlinePipeline(
+ options: const GcodeReadlineOptions(snapshotBatchSize: 200),
+ );
+
+ static const sampleSource = '''
+G0 X0 Y0
+G1 X30 Y0 F1200
+G1 X30 Y18
+G1 X12 Y18
+G0 X6 Y8
+G1 X22 Y8
+G2 X40 Y40
+''';
+
+ final GcodeReadlinePipeline _pipeline;
+ final playbackProgress = ValueNotifier(1);
+ final currentCommandIndex = ValueNotifier(-1);
+
+ GcodeLoadSnapshot? snapshot;
+ String sourceName = '未选择文件';
+ String status = '请选择本地 G-code 文件,或加载内置示例。';
+ bool loading = false;
+ bool isPlaying = false;
+ double speedMultiplier = 1;
+
+ Timer? _playbackTimer;
+ bool _disposed = false;
+
+ Future pickAndParseFile() async {
+ const types = XTypeGroup(
+ label: 'G-code',
+ extensions: ['gcode', 'nc', 'tap', 'txt'],
+ );
+ final file = await openFile(acceptedTypeGroups: [types]);
+ if (file == null || _disposed) return;
+ await parseSnapshots(
+ _pipeline.loadFileInBackground(file.path),
+ sourceName: file.name,
+ );
+ }
+
+ Future loadSample() => parseSnapshots(
+ _pipeline.load(const StringGcodeLineReader(sampleSource)),
+ sourceName: '内置示例',
+ );
+
+ Future parseSnapshots(
+ Stream snapshots, {
+ required String sourceName,
+ }) async {
+ _playbackTimer?.cancel();
+ loading = true;
+ isPlaying = false;
+ this.sourceName = sourceName;
+ snapshot = null;
+ status = '正在读取 $sourceName';
+ _setPlaybackProgress(1);
+ notifyListeners();
+
+ await for (final next in snapshots) {
+ if (_disposed) return;
+ snapshot = next;
+ status = next.message;
+ _setPlaybackProgress(1);
+ notifyListeners();
+ if (next.stage == GcodeLoadStage.parsing) {
+ await Future.delayed(const Duration(milliseconds: 16));
+ }
+ }
+
+ if (_disposed) return;
+ loading = false;
+ notifyListeners();
+ }
+
+ void play() {
+ if ((snapshot?.segments.isEmpty ?? true) || loading) return;
+ _playbackTimer?.cancel();
+ if (playbackProgress.value >= 1) _setPlaybackProgress(0);
+ isPlaying = true;
+ notifyListeners();
+ _playbackTimer = Timer.periodic(const Duration(milliseconds: 16), (_) {
+ if (_disposed) return;
+ _setPlaybackProgress(playbackProgress.value + 0.004 * speedMultiplier);
+ if (playbackProgress.value >= 1) {
+ _playbackTimer?.cancel();
+ isPlaying = false;
+ notifyListeners();
+ }
+ });
+ }
+
+ void pause() {
+ _playbackTimer?.cancel();
+ isPlaying = false;
+ notifyListeners();
+ }
+
+ void resetPlayback() {
+ _playbackTimer?.cancel();
+ _setPlaybackProgress(0);
+ isPlaying = false;
+ notifyListeners();
+ }
+
+ void seekPlayback(double value) => _setPlaybackProgress(value);
+
+ void setSpeed(double value) {
+ speedMultiplier = value;
+ notifyListeners();
+ }
+
+ void selectCommand(int index) {
+ final total = snapshot?.commands.length ?? 0;
+ if (total == 0) return;
+ pause();
+ _setPlaybackProgress((index + 1) / total);
+ }
+
+ void _setPlaybackProgress(double value) {
+ final progress = value.clamp(0.0, 1.0).toDouble();
+ playbackProgress.value = progress;
+ final count = snapshot?.commands.length ?? 0;
+ final index = count == 0
+ ? -1
+ : (progress * count).ceil().clamp(1, count) - 1;
+ if (currentCommandIndex.value != index) {
+ currentCommandIndex.value = index;
+ }
+ }
+
+ @override
+ void dispose() {
+ _disposed = true;
+ _playbackTimer?.cancel();
+ playbackProgress.dispose();
+ currentCommandIndex.dispose();
+ super.dispose();
+ }
+}
diff --git a/example/lib/src/widgets/gcode_canvas_panel.dart b/example/lib/src/widgets/gcode_canvas_panel.dart
new file mode 100644
index 0000000..249419a
--- /dev/null
+++ b/example/lib/src/widgets/gcode_canvas_panel.dart
@@ -0,0 +1,117 @@
+import 'package:flutter/material.dart';
+import 'package:gcode_core/gcode_core.dart';
+
+class GcodeCanvasPanel extends StatelessWidget {
+ const GcodeCanvasPanel({
+ super.key,
+ required this.snapshot,
+ required this.parsing,
+ required this.progress,
+ required this.isPlaying,
+ required this.speedMultiplier,
+ required this.onPlay,
+ required this.onPause,
+ required this.onReset,
+ required this.onSeek,
+ required this.onSpeedChange,
+ });
+
+ final GcodeLoadSnapshot? snapshot;
+ final bool parsing;
+ final double progress;
+ final bool isPlaying;
+ final double speedMultiplier;
+ final VoidCallback onPlay;
+ final VoidCallback onPause;
+ final VoidCallback onReset;
+ final ValueChanged onSeek;
+ final ValueChanged onSpeedChange;
+
+ @override
+ Widget build(BuildContext context) {
+ final segments = snapshot?.segments ?? const [];
+ final errors = snapshot?.errors.length ?? 0;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Expanded(
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: GcodeCanvas(
+ segments: segments,
+ progress: parsing ? 1 : progress,
+ errorCount: errors,
+ bounds: snapshot?.bounds,
+ ),
+ ),
+ Positioned(
+ left: 12,
+ top: 12,
+ child: _CanvasLegend(
+ parsing: parsing,
+ segments: segments.length,
+ mainSegments: segments
+ .where(
+ (segment) => segment.type == GcodeSegmentType.linear,
+ )
+ .length,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ PlaybackControls(
+ isPlaying: isPlaying,
+ progress: parsing ? 1 : progress,
+ speedMultiplier: speedMultiplier,
+ onPlay: onPlay,
+ onPause: onPause,
+ onReset: onReset,
+ onSeek: onSeek,
+ onSpeedChange: onSpeedChange,
+ ),
+ ],
+ );
+ }
+}
+
+class _CanvasLegend extends StatelessWidget {
+ const _CanvasLegend({
+ required this.parsing,
+ required this.segments,
+ required this.mainSegments,
+ });
+
+ final bool parsing;
+ final int segments;
+ final int mainSegments;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surface.withValues(alpha: 0.9),
+ border: Border.all(color: theme.colorScheme.outlineVariant),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
+ child: DefaultTextStyle(
+ style: theme.textTheme.labelMedium!,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(parsing ? '动态解析绘制中' : 'GPU 轨迹绘制'),
+ const SizedBox(height: 4),
+ Text('主线段 G1: $mainSegments'),
+ Text('移动段 G0/G1: $segments'),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/example/lib/src/widgets/gcode_result_panel.dart b/example/lib/src/widgets/gcode_result_panel.dart
new file mode 100644
index 0000000..e659f60
--- /dev/null
+++ b/example/lib/src/widgets/gcode_result_panel.dart
@@ -0,0 +1,90 @@
+import 'package:flutter/material.dart';
+import 'package:gcode_core/gcode_core.dart';
+
+class GcodeResultPanel extends StatelessWidget {
+ const GcodeResultPanel({
+ super.key,
+ required this.snapshot,
+ required this.currentIndex,
+ required this.onCommandTap,
+ });
+
+ final GcodeLoadSnapshot? snapshot;
+ final int currentIndex;
+ final ValueChanged onCommandTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final current = snapshot;
+ if (current == null) {
+ return const Center(child: Text('解析结果会显示在这里'));
+ }
+ return ListView(
+ children: [
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ _Metric(label: '行数', value: current.linesRead.toString()),
+ _Metric(label: '指令', value: current.commands.length.toString()),
+ _Metric(label: '轨迹', value: current.segments.length.toString()),
+ _Metric(label: '错误', value: current.errors.length.toString()),
+ ],
+ ),
+ const SizedBox(height: 16),
+ CommandTimeline(
+ commands: current.commands,
+ errors: current.errors,
+ currentIndex: currentIndex,
+ onTap: onCommandTap,
+ maxHeight: 360,
+ ),
+ const SizedBox(height: 16),
+ Text('解析错误', style: Theme.of(context).textTheme.titleMedium),
+ const SizedBox(height: 8),
+ if (current.errors.isEmpty)
+ const Text('无')
+ else
+ for (final error in current.errors)
+ ListTile(
+ dense: true,
+ leading: const Icon(Icons.warning_amber),
+ title: Text('第 ${error.lineNumber} 行'),
+ subtitle: Text('${error.message}\n${error.rawLine}'),
+ ),
+ ],
+ );
+ }
+}
+
+class _Metric extends StatelessWidget {
+ const _Metric({required this.label, required this.value});
+
+ final String label;
+ final String value;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return SizedBox(
+ width: 78,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label, style: theme.textTheme.labelMedium),
+ const SizedBox(height: 4),
+ Text(value, style: theme.textTheme.titleLarge),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/example/lib/src/widgets/gcode_status_bar.dart b/example/lib/src/widgets/gcode_status_bar.dart
new file mode 100644
index 0000000..0571648
--- /dev/null
+++ b/example/lib/src/widgets/gcode_status_bar.dart
@@ -0,0 +1,47 @@
+import 'package:flutter/material.dart';
+
+class GcodeStatusBar extends StatelessWidget {
+ const GcodeStatusBar({
+ super.key,
+ required this.sourceName,
+ required this.status,
+ required this.loading,
+ });
+
+ final String sourceName;
+ final String status;
+ final bool loading;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ border: Border.all(color: theme.colorScheme.outlineVariant),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Row(
+ children: [
+ if (loading)
+ const SizedBox.square(
+ dimension: 18,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ else
+ const Icon(Icons.route),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ '$sourceName - $status',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/example/pubspec.lock b/example/pubspec.lock
index 81adb81..3c9f869 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -29,10 +29,10 @@ packages:
dependency: transitive
description:
name: clock
- sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
+ sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.flutter-io.cn"
source: hosted
- version: "1.1.2"
+ version: "1.1.3"
collection:
dependency: transitive
description:
@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: cross_file
- sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
+ sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.flutter-io.cn"
source: hosted
- version: "0.3.5+2"
+ version: "0.3.5+5"
cupertino_icons:
dependency: "direct main"
description:
@@ -77,34 +77,34 @@ packages:
dependency: transitive
description:
name: file_selector_android
- sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed"
+ sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c
url: "https://pub.flutter-io.cn"
source: hosted
- version: "0.5.2+6"
+ version: "0.5.2+11"
file_selector_ios:
dependency: transitive
description:
name: file_selector_ios
- sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
+ sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c"
url: "https://pub.flutter-io.cn"
source: hosted
- version: "0.5.3+5"
+ version: "0.5.3+6"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
- sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
+ sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
url: "https://pub.flutter-io.cn"
source: hosted
- version: "0.9.4"
+ version: "0.9.4+1"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
- sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
+ sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
url: "https://pub.flutter-io.cn"
source: hosted
- version: "0.9.5"
+ version: "0.9.5+1"
file_selector_platform_interface:
dependency: transitive
description:
@@ -125,10 +125,10 @@ packages:
dependency: transitive
description:
name: file_selector_windows
- sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
+ sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
url: "https://pub.flutter-io.cn"
source: hosted
- version: "0.9.3+5"
+ version: "0.9.3+6"
flutter:
dependency: "direct main"
description: flutter
@@ -163,7 +163,7 @@ packages:
path: ".."
relative: true
source: path
- version: "0.2.0-dev.1"
+ version: "0.2.0"
http:
dependency: transitive
description:
@@ -269,10 +269,10 @@ packages:
dependency: transitive
description:
name: stack_trace
- sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
+ sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.flutter-io.cn"
source: hosted
- version: "1.12.1"
+ version: "1.12.2"
stream_channel:
dependency: transitive
description:
@@ -317,18 +317,18 @@ packages:
dependency: transitive
description:
name: vector_math
- sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
+ sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.flutter-io.cn"
source: hosted
- version: "2.4.2"
+ version: "2.4.3"
vm_service:
dependency: transitive
description:
name: vm_service
- sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
+ sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.flutter-io.cn"
source: hosted
- version: "15.2.0"
+ version: "15.3.0"
web:
dependency: transitive
description:
@@ -338,5 +338,5 @@ packages:
source: hosted
version: "1.1.1"
sdks:
- dart: ">=3.11.5 <4.0.0"
+ dart: ">=3.12.0 <4.0.0"
flutter: ">=3.47.2"
diff --git a/example/test/gcode_session_controller_test.dart b/example/test/gcode_session_controller_test.dart
new file mode 100644
index 0000000..1d4e86b
--- /dev/null
+++ b/example/test/gcode_session_controller_test.dart
@@ -0,0 +1,42 @@
+import 'package:example/src/gcode_session_controller.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:gcode_core/gcode_core.dart';
+
+void main() {
+ test('loads the sample and exposes its parse error', () async {
+ final controller = GcodeSessionController();
+ addTearDown(controller.dispose);
+
+ await controller.loadSample();
+
+ expect(controller.loading, isFalse);
+ expect(controller.snapshot?.stage, GcodeLoadStage.ready);
+ expect(controller.snapshot?.errors, hasLength(1));
+ expect(controller.sourceName, '内置示例');
+ });
+
+ test('play restarts a completed toolpath from the beginning', () async {
+ final controller = GcodeSessionController();
+ addTearDown(controller.dispose);
+ await controller.loadSample();
+
+ expect(controller.playbackProgress.value, 1);
+ controller.play();
+
+ expect(controller.isPlaying, isTrue);
+ expect(controller.playbackProgress.value, 0);
+ controller.pause();
+ });
+
+ test('selecting a command seeks playback and updates its index', () async {
+ final controller = GcodeSessionController();
+ addTearDown(controller.dispose);
+ await controller.loadSample();
+ final commandCount = controller.snapshot!.commands.length;
+
+ controller.selectCommand(2);
+
+ expect(controller.currentCommandIndex.value, 2);
+ expect(controller.playbackProgress.value, closeTo(3 / commandCount, 1e-9));
+ });
+}
diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart
index 7c72bb2..2d5799c 100644
--- a/example/test/widget_test.dart
+++ b/example/test/widget_test.dart
@@ -1,3 +1,5 @@
+import 'package:example/main.dart';
+import 'package:flutter/material.dart';
import 'package:gcode_core/gcode_core.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -16,4 +18,21 @@ void main() {
expect(last.stage, GcodeLoadStage.ready);
expect(last.commands.length, 2);
});
+
+ testWidgets('example fits a narrow Android-sized viewport', (
+ WidgetTester tester,
+ ) async {
+ tester.view.physicalSize = const Size(320, 640);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+
+ await tester.pumpWidget(const GcodeCoreExampleApp());
+ await tester.pump();
+
+ expect(find.text('G-code'), findsOneWidget);
+ expect(find.byTooltip('示例数据'), findsOneWidget);
+ expect(find.byTooltip('选择 G-code'), findsOneWidget);
+ expect(tester.takeException(), isNull);
+ });
}
diff --git a/example/tool/macos_run.py b/example/tool/macos_run.py
index 76d7be6..646fa7e 100644
--- a/example/tool/macos_run.py
+++ b/example/tool/macos_run.py
@@ -3,6 +3,7 @@
import argparse
import os
import pathlib
+import platform
import plistlib
import subprocess
@@ -10,6 +11,7 @@
parser.add_argument('--mode', choices=['debug', 'profile', 'release'], default='debug')
parser.add_argument('--target', default='lib/main.dart')
parser.add_argument('--build-only', action='store_true')
+parser.add_argument('--arch', choices=['arm64', 'x86_64'], default=platform.machine())
args = parser.parse_args()
root = pathlib.Path(__file__).resolve().parents[1]
subprocess.run(['python3',str(root/'tool/build_gpu_shaders.py')],check=True)
@@ -17,7 +19,7 @@
subprocess.run(['flutter','build','macos',f'--{args.mode}','--config-only','-t',args.target],cwd=root,check=True)
subprocess.run(['xcodebuild','-workspace','macos/Runner.xcworkspace','-scheme','Runner',
'-configuration',args.mode.capitalize(),'-derivedDataPath','build/macos',
- '-destination','platform=macOS,arch=arm64',
+ '-destination',f'platform=macOS,arch={args.arch}',
'CC='+str(root/'tool/macos/compiler_probe.py'),'COMPILER_INDEX_STORE_ENABLE=NO'],cwd=root,check=True)
products=root/'build/macos/Build/Products'/args.mode.capitalize()
apps=list(products.glob('*.app'))
diff --git a/lib/src/rendering/gpu_toolpath_layer.dart b/lib/src/rendering/gpu_toolpath_layer.dart
index 12a465e..bbd71a7 100644
--- a/lib/src/rendering/gpu_toolpath_layer.dart
+++ b/lib/src/rendering/gpu_toolpath_layer.dart
@@ -87,10 +87,12 @@ class _GpuResources {
gpu.gpuContext.createDeviceBufferWithCopy(ByteData.sublistView(data)),
offsetInBytes: 0,
lengthInBytes: data.lengthInBytes);
+ host = gpu.gpuContext.createHostBuffer();
}
final gpu.RenderPipeline pipeline;
final gpu.RenderPipeline guides;
late final gpu.BufferView quad;
+ late final gpu.HostBuffer host;
ToolpathViewport? viewport;
GcodeBounds? suppliedBounds;
gpu.GpuImageSurface? surface;
@@ -104,6 +106,12 @@ class _GpuResources {
surface = null;
vertices = null;
segments = null;
+ viewport = null;
+ suppliedBounds = null;
+ bounds = null;
+ size = null;
+ width = null;
+ vertexCount = 0;
}
void prepare(GpuToolpathLayer input, Size newSize, double dpr) {
@@ -220,7 +228,7 @@ class _GpuImageCompositor extends CustomPainter {
gpu.ColorAttachment(texture: frame.colorTexture)));
pass.setColorBlendEnable(true);
pass.setColorBlendEquation(gpu.ColorBlendEquation());
- final host = gpu.gpuContext.createHostBuffer();
+ final host = resources.host..reset();
_drawGuides(pass, host, size, 0);
if (resources.vertexCount > 0) {
pass.clearBindings();
@@ -282,5 +290,12 @@ class _GpuImageCompositor extends CustomPainter {
}
@override
- bool shouldRepaint(covariant _GpuImageCompositor oldDelegate) => true;
+ bool shouldRepaint(covariant _GpuImageCompositor oldDelegate) {
+ return !identical(resources, oldDelegate.resources) ||
+ !identical(input.segments, oldDelegate.input.segments) ||
+ input.bounds != oldDelegate.input.bounds ||
+ input.progress != oldDelegate.input.progress ||
+ input.style != oldDelegate.input.style ||
+ dpr != oldDelegate.dpr;
+ }
}
diff --git a/lib/src/widgets/command_timeline.dart b/lib/src/widgets/command_timeline.dart
index 9b8f78b..f747aad 100644
--- a/lib/src/widgets/command_timeline.dart
+++ b/lib/src/widgets/command_timeline.dart
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import '../models/gcode_command.dart';
import '../parser/gcode_parse_result.dart';
-class CommandTimeline extends StatelessWidget {
+class CommandTimeline extends StatefulWidget {
const CommandTimeline({
super.key,
required this.commands,
@@ -20,12 +20,33 @@ class CommandTimeline extends StatelessWidget {
final double? maxHeight;
@override
- Widget build(BuildContext context) {
- final items = _buildTimelineItems();
+ State createState() => _CommandTimelineState();
+}
+
+class _CommandTimelineState extends State {
+ late List<_TimelineItem> _items;
+
+ @override
+ void initState() {
+ super.initState();
+ _items = _buildTimelineItems();
+ }
+ @override
+ void didUpdateWidget(covariant CommandTimeline oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (!identical(widget.commands, oldWidget.commands) ||
+ !identical(widget.errors, oldWidget.errors)) {
+ _items = _buildTimelineItems();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
return Container(
- constraints:
- maxHeight != null ? BoxConstraints(maxHeight: maxHeight!) : null,
+ constraints: widget.maxHeight != null
+ ? BoxConstraints(maxHeight: widget.maxHeight!)
+ : null,
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
@@ -39,12 +60,12 @@ class CommandTimeline extends StatelessWidget {
child: Row(
children: [
Text(
- '指令列表 (${commands.length})',
+ '指令列表 (${widget.commands.length})',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
- if (errors.isNotEmpty)
+ if (widget.errors.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Container(
@@ -55,7 +76,7 @@ class CommandTimeline extends StatelessWidget {
borderRadius: BorderRadius.circular(4),
),
child: Text(
- '${errors.length} 错误',
+ '${widget.errors.length} 错误',
style: const TextStyle(
fontSize: 11,
color: Colors.red,
@@ -71,20 +92,20 @@ class CommandTimeline extends StatelessWidget {
Flexible(
child: ListView.builder(
shrinkWrap: true,
- itemCount: items.length,
+ itemCount: _items.length,
itemBuilder: (context, index) {
- final item = items[index];
+ final item = _items[index];
final cmd = item.command;
final error = item.error;
- final commandIndex = cmd == null ? -1 : commands.indexOf(cmd);
+ final commandIndex = item.commandIndex;
final isCurrent =
- commandIndex >= 0 && commandIndex == currentIndex;
+ commandIndex >= 0 && commandIndex == widget.currentIndex;
final hasError = error != null;
final code = cmd?.code;
return InkWell(
- onTap: onTap != null && commandIndex >= 0
- ? () => onTap!(commandIndex)
+ onTap: widget.onTap != null && commandIndex >= 0
+ ? () => widget.onTap!(commandIndex)
: null,
child: Container(
padding:
@@ -179,8 +200,9 @@ class CommandTimeline extends StatelessWidget {
List<_TimelineItem> _buildTimelineItems() {
final items = <_TimelineItem>[
- for (final command in commands) _TimelineItem.command(command),
- for (final error in errors) _TimelineItem.error(error),
+ for (final (index, command) in widget.commands.indexed)
+ _TimelineItem.command(command, index),
+ for (final error in widget.errors) _TimelineItem.error(error),
];
items.sort((a, b) => a.lineNumber.compareTo(b.lineNumber));
return items;
@@ -193,12 +215,15 @@ class _TimelineItem {
required this.rawLine,
this.command,
this.error,
+ this.commandIndex = -1,
});
- factory _TimelineItem.command(GcodeCommand command) => _TimelineItem._(
+ factory _TimelineItem.command(GcodeCommand command, int commandIndex) =>
+ _TimelineItem._(
lineNumber: command.lineNumber,
rawLine: command.rawLine,
command: command,
+ commandIndex: commandIndex,
);
factory _TimelineItem.error(GcodeParseError error) => _TimelineItem._(
@@ -211,4 +236,5 @@ class _TimelineItem {
final String rawLine;
final GcodeCommand? command;
final GcodeParseError? error;
+ final int commandIndex;
}
diff --git a/pubspec.yaml b/pubspec.yaml
index 6635ab4..ccdeb53 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,8 @@
name: gcode_core
description: G-code parsing, line reading, toolpath building, and Flutter visualization widgets.
-publish_to: 'none'
-version: 0.2.0-dev.1
+repository: https://github.com/lizy-coding/gcode_core
+issue_tracker: https://github.com/lizy-coding/gcode_core/issues
+version: 0.2.0
environment:
sdk: '>=3.6.0 <4.0.0'