diff --git a/.all-contributorsrc b/.all-contributorsrc index 95122bc1fd..9beea4235e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1733,6 +1733,15 @@ "contributions": [ "bug" ] + }, + { + "login": "kjhollen", + "name": "Kate Hollenbach", + "avatar_url": "https://avatars.githubusercontent.com/u/78966?v=4", + "profile": "https://github.com/kjhollen", + "contributions": [ + "bug" + ] } ], "repoType": "github", @@ -1740,4 +1749,4 @@ "skipCi": true, "commitConvention": "angular", "commitType": "docs" -} +} \ No newline at end of file diff --git a/.github/workflows/contributors-png.yml b/.github/workflows/contributors-png.yml new file mode 100644 index 0000000000..040bfe7256 --- /dev/null +++ b/.github/workflows/contributors-png.yml @@ -0,0 +1,40 @@ +name: Generate Contributors PNG + +on: + push: + paths: + - '.all-contributorsrc' + +jobs: + genimage: + if: github.ref == 'refs/heads/main' && github.repository == 'processing/processing4' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install canvas + + - name: Run contributors-png generator + run: node utils/contributors-png.js + + - name: Reset all changes except contributors.png + run: | + git restore --staged . + git add contributors.png + git checkout -- . + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "Update contributors.png from .all-contributorsrc" + branch: update-contributors-png + title: "chore: update contributors.png from .all-contributorsrc" + body: "This PR updates the contributors.png to reflect changes in .all-contributorsrc" + add-paths: contributors.png + token: ${{ secrets.CONTRIBUTOR_PNG_ACCESS_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e21fce7a40..04a4985699 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,8 @@ name: Releases on: - release: - types: [published] + push: + tags: + - 'processing-*' jobs: version: @@ -9,24 +10,68 @@ jobs: outputs: revision: ${{ steps.tag_info.outputs.revision }} version: ${{ steps.tag_info.outputs.version }} + is_prerelease: ${{ steps.tag_info.outputs.is_prerelease }} + asset_version: ${{ steps.tag_info.outputs.asset_version }} + release_title: ${{ steps.tag_info.outputs.release_title }} steps: - name: Extract version and revision id: tag_info shell: bash run: | + # Tags are shaped processing--[-rc]. + # A trailing -rc marks a release candidate (a public GitHub + # prerelease); without it the tag is a final release. TAG_NAME="${GITHUB_REF#refs/tags/}" REVISION=$(echo "$TAG_NAME" | cut -d'-' -f2) VERSION=$(echo "$TAG_NAME" | cut -d'-' -f3) + RC=$(echo "$TAG_NAME" | cut -d'-' -f4) # Set outputs for use in later jobs or steps echo "revision=$REVISION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT + + # The packaged version stays numeric (installer formats require it); + # the RC marker only rides along in the release title and asset names. + if [ -n "$RC" ]; then + RC_UPPER=$(echo "$RC" | tr '[:lower:]' '[:upper:]') + echo "is_prerelease=true" >> $GITHUB_OUTPUT + echo "asset_version=${VERSION}-${RC}" >> $GITHUB_OUTPUT + echo "release_title=Processing ${VERSION} ${RC_UPPER}" >> $GITHUB_OUTPUT + else + echo "is_prerelease=false" >> $GITHUB_OUTPUT + echo "asset_version=${VERSION}" >> $GITHUB_OUTPUT + echo "release_title=Processing ${VERSION}" >> $GITHUB_OUTPUT + fi + + create-draft: + name: Create draft release + runs-on: ubuntu-latest + needs: version + permissions: + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + TITLE: ${{ needs.version.outputs.release_title }} + steps: + - name: Create draft release + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release for $TAG already exists; leaving it as-is." + else + gh release create "$TAG" \ + --draft \ + --title "$TITLE" \ + --generate-notes + fi + reference: name: Publish Processing Reference to release runs-on: ubuntu-latest permissions: contents: write - needs: version + needs: [version, create-draft] steps: - name: Checkout Website Repository uses: actions/checkout@v4 @@ -42,17 +87,25 @@ jobs: run: npm run build - name: Make reference.zip run: npm run zip + - name: Stage reference asset + env: + ASSET_VERSION: ${{ needs.version.outputs.asset_version }} + run: | + mkdir -p release-assets + cp reference.zip "release-assets/processing-${ASSET_VERSION}-reference.zip" - name: Upload reference to release - uses: svenstaro/upload-release-action@v2 + uses: softprops/action-gh-release@v2 with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - asset_name: processing-${{ needs.version.outputs.version }}-reference.zip - file: reference.zip + draft: true + tag_name: ${{ github.ref_name }} + files: release-assets/processing-${{ needs.version.outputs.asset_version }}-reference.zip publish-maven: name: Publish Processing Libraries to Maven Central runs-on: ubuntu-latest - needs: version + needs: [version, release-windows, release-macos, release-linux] + # Release candidates must not notify Maven Central. + if: ${{ needs.version.outputs.is_prerelease != 'true' }} steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -78,7 +131,9 @@ jobs: publish-gradle: name: Publish Processing Plugins to Gradle Plugin Portal runs-on: ubuntu-latest - needs: version + needs: [version, release-windows, release-macos, release-linux] + # Release candidates must not notify the Gradle Plugin Portal. + if: ${{ needs.version.outputs.is_prerelease != 'true' }} steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -97,9 +152,9 @@ jobs: ORG_GRADLE_PROJECT_version: ${{ needs.version.outputs.version }} ORG_GRADLE_PROJECT_group: ${{ vars.GRADLE_GROUP }} - + - name: Publish internal plugins to Gradle Plugin Portal - run: ./gradlew -c gradle/plugins/settings.gradle.kts publishPlugins + run: ./gradlew -p gradle/plugins publishPlugins env: GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} @@ -114,7 +169,7 @@ jobs: release-windows: name: (windows/${{ matrix.arch }}) Create Processing Release runs-on: ${{ matrix.os }} - needs: version + needs: [version, create-draft] permissions: contents: write strategy: @@ -154,24 +209,28 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 - - name: Upload portable version - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - asset_name: processing-${{ needs.version.outputs.version }}-windows-${{ matrix.arch }}-portable.zip - file: app/build/compose/binaries/main/Processing-${{ needs.version.outputs.version }}.zip + - name: Stage release assets + shell: bash + env: + VERSION: ${{ needs.version.outputs.version }} + ASSET_VERSION: ${{ needs.version.outputs.asset_version }} + ARCH: ${{ matrix.arch }} + run: | + mkdir -p release-assets + cp "app/build/compose/binaries/main/Processing-${VERSION}.zip" "release-assets/processing-${ASSET_VERSION}-windows-${ARCH}-portable.zip" + cp "app/build/compose/binaries/main/msi/Processing-${VERSION}.msi" "release-assets/processing-${ASSET_VERSION}-windows-${ARCH}.msi" - - name: Upload installer - uses: svenstaro/upload-release-action@v2 + - name: Upload release assets + uses: softprops/action-gh-release@v2 with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - asset_name: processing-${{ needs.version.outputs.version }}-windows-${{ matrix.arch }}.msi - file: app/build/compose/binaries/main/msi/Processing-${{ needs.version.outputs.version }}.msi + draft: true + tag_name: ${{ github.ref_name }} + files: release-assets/* release-macos: name: (macOS/${{ matrix.arch }}) Create Processing Release runs-on: macos-latest - needs: version + needs: [version, create-draft] permissions: contents: write strategy: @@ -209,24 +268,27 @@ jobs: ORG_GRADLE_PROJECT_compose.desktop.mac.notarization.password: ${{ secrets.PROCESSING_APP_PASSWORD }} ORG_GRADLE_PROJECT_compose.desktop.mac.notarization.teamID: ${{ secrets.PROCESSING_TEAM_ID }} - - name: Upload portables to release - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - asset_name: processing-${{ needs.version.outputs.version }}-macos-${{ matrix.arch }}-portable.zip - file: app/build/compose/binaries/main/Processing-${{ needs.version.outputs.version }}.zip + - name: Stage release assets + env: + VERSION: ${{ needs.version.outputs.version }} + ASSET_VERSION: ${{ needs.version.outputs.asset_version }} + ARCH: ${{ matrix.arch }} + run: | + mkdir -p release-assets + cp "app/build/compose/binaries/main/Processing-${VERSION}.zip" "release-assets/processing-${ASSET_VERSION}-macos-${ARCH}-portable.zip" + cp "app/build/compose/binaries/main/dmg/Processing-${VERSION}.dmg" "release-assets/processing-${ASSET_VERSION}-macos-${ARCH}.dmg" - - name: Upload installers to release - uses: svenstaro/upload-release-action@v2 + - name: Upload release assets + uses: softprops/action-gh-release@v2 with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - asset_name: processing-${{ needs.version.outputs.version }}-macos-${{ matrix.arch }}.dmg - file: app/build/compose/binaries/main/dmg/Processing-${{ needs.version.outputs.version }}.dmg + draft: true + tag_name: ${{ github.ref_name }} + files: release-assets/* release-linux: name: (linux/${{ matrix.arch }}) Create Processing Release runs-on: ${{ matrix.os }} - needs: version + needs: [version, create-draft] permissions: contents: write strategy: @@ -254,19 +316,23 @@ jobs: ORG_GRADLE_PROJECT_revision: ${{ needs.version.outputs.revision }} ORG_GRADLE_PROJECT_compose.desktop.verbose: true - - name: Upload portable to release - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - asset_name: processing-${{ needs.version.outputs.version }}-linux-${{ matrix.arch }}-portable.zip - file: app/build/compose/binaries/main/Processing-${{ needs.version.outputs.version }}.zip + - name: Stage release assets + env: + VERSION: ${{ needs.version.outputs.version }} + ASSET_VERSION: ${{ needs.version.outputs.asset_version }} + ARCH: ${{ matrix.arch }} + DEB: ${{ matrix.deb }} + run: | + mkdir -p release-assets + cp "app/build/compose/binaries/main/Processing-${VERSION}.zip" "release-assets/processing-${ASSET_VERSION}-linux-${ARCH}-portable.zip" + cp "app/build/compose/binaries/main/deb/processing_${VERSION}-1_${DEB}.deb" "release-assets/processing-${ASSET_VERSION}-linux-${ARCH}.deb" - - name: Upload installer to release - uses: svenstaro/upload-release-action@v2 + - name: Upload release assets + uses: softprops/action-gh-release@v2 with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - asset_name: processing-${{ needs.version.outputs.version }}-linux-${{ matrix.arch }}.deb - file: app/build/compose/binaries/main/deb/processing_${{ needs.version.outputs.version }}-1_${{ matrix.deb }}.deb + draft: true + tag_name: ${{ github.ref_name }} + files: release-assets/* - name: Add artifact uses: actions/upload-artifact@v4 @@ -278,7 +344,7 @@ jobs: release-linux-snap: name: (linux/${{ matrix.arch }}) Create Processing Snap Release runs-on: ${{ matrix.os }} - needs: [version, release-linux] + needs: [version, create-draft, release-linux] permissions: contents: write strategy: @@ -316,21 +382,34 @@ jobs: ORG_GRADLE_PROJECT_snapname: ${{ vars.SNAP_NAME }} ORG_GRADLE_PROJECT_snapconfinement: ${{ vars.SNAP_CONFINEMENT }} + - name: Stage release assets + env: + VERSION: ${{ needs.version.outputs.version }} + ASSET_VERSION: ${{ needs.version.outputs.asset_version }} + ARCH: ${{ matrix.arch }} + DEB: ${{ matrix.deb }} + SNAP_NAME: ${{ vars.SNAP_NAME }} + run: | + mkdir -p release-assets + cp "app/build/compose/binaries/main/${SNAP_NAME}_${VERSION}_${DEB}.snap" "release-assets/processing-${ASSET_VERSION}-linux-${ARCH}.snap" + - name: Upload snap to release - uses: svenstaro/upload-release-action@v2 + uses: softprops/action-gh-release@v2 with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - asset_name: processing-${{ needs.version.outputs.version }}-linux-${{ matrix.arch }}.snap - file: app/build/compose/binaries/main/${{ vars.SNAP_NAME }}_${{ needs.version.outputs.version }}_${{ matrix.deb }}.snap + draft: true + tag_name: ${{ github.ref_name }} + files: release-assets/processing-${{ needs.version.outputs.asset_version }}-linux-${{ matrix.arch }}.snap - name: Upload snap to Snap Store + # Release candidates must not push to the Snap Store. + if: ${{ needs.version.outputs.is_prerelease != 'true' }} run: snapcraft upload --release=beta app/build/compose/binaries/main/${{ vars.SNAP_NAME }}_${{ needs.version.outputs.version }}_${{ matrix.deb }}.snap env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.PROCESSING_SNAPCRAFT_TOKEN }} release-linux-flatpak: name: (linux/${{ matrix.arch }}) Create Processing Flatpak Release runs-on: ${{ matrix.os }} - needs: [ version, release-linux ] + needs: [version, create-draft, release-linux] container: image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48 options: --privileged @@ -375,9 +454,54 @@ jobs: cache-key: flatpak-builder-${{ github.sha }} arch: ${{ matrix.farch }} + - name: Stage release assets + env: + ASSET_VERSION: ${{ needs.version.outputs.asset_version }} + ARCH: ${{ matrix.arch }} + run: | + mkdir -p release-assets + cp processing.flatpak "release-assets/processing-${ASSET_VERSION}-linux-${ARCH}.flatpak" + - name: Upload Flatpak to release - uses: svenstaro/upload-release-action@v2 + uses: softprops/action-gh-release@v2 with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - asset_name: processing-${{ needs.version.outputs.version }}-linux-${{ matrix.arch }}.flatpak - file: processing.flatpak \ No newline at end of file + draft: true + tag_name: ${{ github.ref_name }} + files: release-assets/processing-${{ needs.version.outputs.asset_version }}-linux-${{ matrix.arch }}.flatpak + + publish-release: + name: Publish release + runs-on: ubuntu-latest + environment: release + needs: + - version + - create-draft + - reference + - publish-maven + - publish-gradle + - release-windows + - release-macos + - release-linux + - release-linux-snap + - release-linux-flatpak + # Run once nothing has failed. publish-maven/publish-gradle are skipped for + # release candidates, and a skipped dependency would otherwise skip this job. + if: ${{ !failure() && !cancelled() }} + permissions: + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + IS_PRERELEASE: ${{ needs.version.outputs.is_prerelease }} + steps: + - name: Mark release as published + run: | + # Release candidates go out as a public prerelease (downloadable, but + # not marked "latest" so package-manager watchers ignore them); final + # releases are marked latest. + if [ "$IS_PRERELEASE" = "true" ]; then + gh release edit "$TAG" --draft=false --prerelease + else + gh release edit "$TAG" --draft=false --latest + fi diff --git a/.gitignore b/.gitignore index a6e0752889..3a06a49298 100644 --- a/.gitignore +++ b/.gitignore @@ -139,6 +139,8 @@ generated/ /java/gradle/build /core/examples/build /java/gradle/example/.processing + +libprocessing/ffi/include/* /app/windows/obj /java/android/example/build /java/android/example/.processing diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..f430a4bd23 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libprocessing"] + path = libprocessing + url = https://github.com/processing/libprocessing diff --git a/BUILD.md b/BUILD.md index 1216f2e952..2cc31446a4 100644 --- a/BUILD.md +++ b/BUILD.md @@ -4,7 +4,21 @@ Great to see you are interested in contributing to Processing. To get started yo ## IntelliJ IDEA (recommended) -First, [download the IntelliJ IDEA Community Edition](https://www.jetbrains.com/idea/download/). Make sure to select the "Community Edition", not "Ultimate". The Community Edition is free and built on open-source software. You may need to scroll down to find the download link. +_**Note:** A paid subscription is **not** required. Everything needed to build and work on Processing is available for free in IntelliJ IDEA._ + +1. [Download IntelliJ IDEA](https://www.jetbrains.com/idea/download/) and install it. +1. Clone the `Processing4` repository locally. +1. Open the cloned repository in **IntelliJ IDEA**. +1. When prompted, select **Trust Project**. (You can preview the project in Safe Mode, but you will not be able to build Processing.) +1. If IntelliJ asks whether to import the Gradle project, select **Load Gradle Project**. +1. Make sure IntelliJ and Gradle are both using **JDK 17**: + * Go to **`File > Project Structure > Project`** + * Set **Project SDK** to **JDK 17** (we recommend **Eclipse Temurin**) + * If needed, choose **Download JDK…** and install **Version 17** + * Then go to **`Settings > Build, Execution, Deployment > Build Tools > Gradle`** + * Set **Gradle JVM** to the same **JDK 17** +1. Wait for Gradle sync to finish. +1. Click the green **Run** button in the top right to build and launch Processing. You can also use this menu to start a debug session. > [!TIP] > If you encounter any issues with this process, Read the [Troubleshooting and Setup Tips for IntelliJ IDEA](#troubleshooting-and-setup-tips-intellij-idea) @@ -42,7 +56,7 @@ If you don't have them installed, you will need to install [Git](https://git-scm 1. **Clone the repository:** ```bash - git clone https://github.com/processing/processing4.git + git clone --recursive https://github.com/processing/processing4.git cd processing4 ``` @@ -54,8 +68,49 @@ If you don't have them installed, you will need to install [Git](https://git-scm - [macOS (Apple Silicon)](https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.15%2B6/OpenJDK17U-jdk_aarch64_mac_hotspot_17.0.15_6.pkg) - [Other platforms](https://adoptium.net/temurin/releases/?package=jdk&version=17&os=any&arch=any) +### macOS: +```bash +brew install --cask temurin@17 +```` + +### Windows (using winget): +```bash +winget install --id=EclipseAdoptium.Temurin.17.JDK -e +``` + +### SDKMAN! + +[SDKMAN!](https://sdkman.io/) is a useful tool for developers working on multiple versions of the JVM. + +## WebGPU Support (Optional) + +To build Processing with the experimental WebGPU renderer, you need JDK 25, Rust, and jextract. + +### Install Temurin JDK 25 + +```bash +brew install --cask temurin@25 # macOS +``` + +### Install `jextract` + +`jextract` generates Java bindings from C header files. +You can download it [here](https://jdk.java.net/jextract/) or install it using SDKMAN!: + +```bash +sdk install jextract +```` + +### Build with WebGPU + +```bash +./gradlew build -PenableWebGPU=true +``` + 3. **Set the `JAVA_HOME` environment variable:** +It may be necessary to set the `JAVA_HOME` environment variable to point to your Temurin JDK installation. + ```bash export JAVA_HOME=/path/to/temurin/jdk-17.0.15+6/ ``` @@ -138,7 +193,7 @@ If you’re building Processing using IntelliJ IDEA and something’s not workin ### Use the Correct JDK (temurin-17) -Make sure IntelliJ is using **temurin-17**, not another version. Some users have reported issues with ms-17. +Make sure IntelliJ is using **temurin-17**, not another version. If building with WebGPU (`-PenableWebGPU=true`), use **temurin-25** instead. 1. Go to **File > Project Structure > Project** 2. Set the **Project SDK** to: `temurin-17 java version "17.0.15"` @@ -149,6 +204,7 @@ If it is not already installed, you can download it by: 1. Clicking the SDK input field and then selecting the `Download JDK...` option from the menu 2. Select Version: `17`, Vendor: `Eclipse Temurin (AdoptOpenJDK HotSpot)` +JDK Download ![JDK Download](.github/media/troubleshooting-Intellij-download-jdk.png) diff --git a/README.md b/README.md index c71c919019..6c19b6923e 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ For licensing information about the Processing website see the [processing-websi Copyright (c) 2015-now The Processing Foundation ## Contributors +![Grid of avatars representing contributors to the Processing4 project](contributors.png) + See [CONTRIBUTORS.md](./CONTRIBUTORS.md) for a list of all contributors to the project. This project follows the [all-contributors specification](https://github.com/all-contributors/all-contributors) and the [Emoji Key](https://all-contributors.github.io/emoji-key/) ✨ for contribution types. Detailed instructions on how to add yourself or add contribution emojis to your name are [here](https://github.com/processing/processing4/issues/839). You can also post an issue or comment on a pull request with the text: `@all-contributors please add @YOUR-USERNAME for THINGS` (where `THINGS` is a comma-separated list of entries from the [list of possible contribution types](https://all-contributors.github.io/emoji-key/)) and our nice bot will add you to [CONTRIBUTORS.md](./CONTRIBUTORS.md) automatically! diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f91e6d98c..c536cc4658 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,9 +5,11 @@ import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask import org.jetbrains.compose.internal.de.undercouch.gradle.tasks.download.Download +import org.gradle.process.ExecOperations import java.io.FileOutputStream import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream +import javax.inject.Inject // TODO: Update to 2.10.20 and add hot-reloading: https://github.com/JetBrains/compose-hot-reload @@ -44,6 +46,9 @@ sourceSets{ kotlin{ srcDirs("test") } + java{ + srcDirs("test") + } } } @@ -51,14 +56,17 @@ compose.desktop { application { mainClass = "processing.app.ProcessingKt" - jvmArgs(*listOf( - Pair("processing.version", rootProject.version), - Pair("processing.revision", findProperty("revision") ?: Int.MAX_VALUE), - Pair("processing.contributions.source", "https://contributions.processing.org/contribs"), - Pair("processing.download.page", "https://processing.org/download/"), - Pair("processing.download.latest", "https://processing.org/download/latest.txt"), - Pair("processing.tutorials", "https://processing.org/tutorials/"), - ).map { "-D${it.first}=${it.second}" }.toTypedArray()) + jvmArgs( + "--enable-native-access=ALL-UNNAMED", // Required for Java 25 native library access + *listOf( + Pair("processing.version", rootProject.version), + Pair("processing.revision", findProperty("revision") ?: Int.MAX_VALUE), + Pair("processing.contributions.source", "https://contributions.processing.org/contribs"), + Pair("processing.download.page", "https://processing.org/download/"), + Pair("processing.download.latest", "https://processing.org/download/latest.txt"), + Pair("processing.tutorials", "https://processing.org/tutorials/"), + ).map { "-D${it.first}=${it.second}" }.toTypedArray() + ) nativeDistributions{ modules("jdk.jdi", "java.compiler", "jdk.accessibility", "jdk.zipfs", "java.management.rmi", "java.scripting", "jdk.httpserver") @@ -96,7 +104,8 @@ compose.desktop { iconFile = rootProject.file("build/linux/processing.png") // Fix fonts on some Linux distributions jvmArgs("-Dawt.useSystemAAFontSettings=on") - + // Enable access to restricted methods; see initBase in LinuxPlatform.java + jvmArgs("--add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED") } } } @@ -136,6 +145,8 @@ dependencies { testImplementation(libs.mockitoKotlin) testImplementation(libs.junitJupiter) testImplementation(libs.junitJupiterParams) + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -186,7 +197,7 @@ tasks.register("packageCustomDmg"){ onlyIf { OperatingSystem.current().isMacOsX } group = "compose desktop" - dependsOn(distributable(), "installCreateDmg") + dependsOn("signApp", "installCreateDmg") val packageName = distributable().packageName.get() val dir = distributable().destinationDir.get() @@ -359,12 +370,12 @@ tasks.register("zipDistributable"){ dependsOn("createDistributable", "setExecutablePermissions") group = "compose desktop" - val dir = distributable().destinationDir.get() - val packageName = distributable().packageName.get() + val dir = provider { distributable().destinationDir.get() } + val packageName = provider { distributable().packageName.get() } from(dir){ eachFile{ permissions{ unix("755") } } } archiveBaseName.set(packageName) - destinationDirectory.set(dir.file("../").asFile) + destinationDirectory.set(layout.dir(provider { dir.get().file("../").asFile})) } afterEvaluate{ @@ -390,6 +401,25 @@ afterEvaluate{ } } +val verifySignedMacApp = tasks.register("verifySignedMacApp") { + onlyIf { OperatingSystem.current().isMacOsX } + dependsOn("createDistributable") + group = "compose desktop" + + commandLine( + "codesign", + "-vvv", + "--deep", + "--strict", + layout.buildDirectory.dir("compose/binaries/main/app/Processing.app").get().asFile.absolutePath + ) +} + +afterEvaluate { + tasks.named("notarizeDmg").configure { + dependsOn(verifySignedMacApp) + } +} // LEGACY TASKS // Most of these are shims to be compatible with the old build system @@ -411,12 +441,19 @@ tasks.register("includeJavaMode") { from(java.configurations.runtimeClasspath) into(composeResources("modes/java/mode")) duplicatesStrategy = DuplicatesStrategy.EXCLUDE - dirPermissions { unix("rwx------") } } +val enableWebGPU = findProperty("enableWebGPU")?.toString()?.toBoolean() ?: false + tasks.register("includeJdk") { - from(Jvm.current().javaHome.absolutePath) + val jdkVersion = if (enableWebGPU) 25 else 17 + val jdkHome = project.the().launcherFor { + languageVersion.set(JavaLanguageVersion.of(jdkVersion)) + }.map { it.metadata.installationPath.asFile } + + from(jdkHome) destinationDir = composeResources("jdk").get().asFile + dirPermissions { unix("rwx------") } fileTree(destinationDir).files.forEach { file -> file.setWritable(true, false) file.setReadable(true, false) @@ -497,7 +534,12 @@ tasks.register("includeProcessingResources"){ finalizedBy("signResources") } -tasks.register("signResources"){ +// Project.exec was removed in Gradle 9; an injected ExecOperations is the replacement +interface ExecOps { + @get:Inject val execOps: ExecOperations +} + +tasks.register("signResources") { onlyIf { OperatingSystem.current().isMacOsX && @@ -505,6 +547,9 @@ tasks.register("signResources"){ } group = "compose desktop" val resourcesPath = composeResources("") + val entitlements = file("macos/entitlements.plist").absolutePath + + val execOps = objects.newInstance().execOps // find jars in the resources directory val jars = mutableListOf() @@ -543,8 +588,8 @@ tasks.register("signResources"){ exclude("*.so") exclude("*.dll") }.forEach{ file -> - exec { - commandLine("codesign", "--timestamp", "--force", "--deep","--options=runtime", "--sign", "Developer ID Application", file) + execOps.exec { + commandLine("codesign", "--timestamp", "--force", "--deep","--options=runtime", "--entitlements", entitlements, "--sign", "Developer ID Application", file) } } jars.forEach { file -> @@ -571,9 +616,36 @@ tasks.register("signResources"){ } file(composeResources("Info.plist")).delete() } +} + +/* for mac, perform one final signature of the whole app before submitting + * the app for notarization. + */ +tasks.register("signApp"){ + onlyIf { + OperatingSystem.current().isMacOsX + && + compose.desktop.application.nativeDistributions.macOS.signing.sign.get() + } + + group = "compose desktop" + dependsOn("createDistributable", "setExecutablePermissions") + val packageName = distributable().packageName.get() + val dir = distributable().destinationDir.get() + val app = dir.file("$packageName.app").asFile + commandLine( + "codesign", + "--timestamp", + "--force", + "--deep", + "--options=runtime", + "--entitlements", file("macos/entitlements.plist").absolutePath, + "--sign", "Developer ID Application", + app) } + tasks.register("setExecutablePermissions") { description = "Sets executable permissions on binaries in Processing.app resources" group = "compose desktop" diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index 49a8625e51..918d1db8e2 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -1216,7 +1216,7 @@ private Editor openSketchBundle(String path) { File destFolder = File.createTempFile("zip", "tmp", untitledFolder); if (!destFolder.delete() || !destFolder.mkdirs()) { // Hard to imagine why this would happen, but... - System.err.println("Could not create temporary folder " + destFolder); + Messages.showWarning("Could not create temporary folder " + destFolder); return null; } Util.unzip(zipFile, destFolder); @@ -1228,11 +1228,11 @@ private Editor openSketchBundle(String path) { return handleOpenUntitled(sketchFile.getAbsolutePath()); } } else { - System.err.println("Expecting one folder inside " + + Messages.showWarning("Expecting one folder inside " + SKETCH_BUNDLE_EXT + " file, found " + fileList.length + "."); } } else { - System.err.println("Could not read " + destFolder); + Messages.showWarning("Could not read " + destFolder); } } catch (IOException e) { e.printStackTrace(); diff --git a/app/src/processing/app/UpdateCheck.java b/app/src/processing/app/UpdateCheck.java index 20c91dd38c..debe32417c 100644 --- a/app/src/processing/app/UpdateCheck.java +++ b/app/src/processing/app/UpdateCheck.java @@ -204,12 +204,15 @@ protected boolean promptToOpenContributionManager() { */ - protected int readInt(String filename) throws IOException { + protected static int readInt(String filename) throws IOException { URL url = new URL(filename); - InputStream stream = url.openStream(); + + // try-with-resources auto closes things of type "Closeable" even the code throws an error + try(InputStream stream = url.openStream(); InputStreamReader isr = new InputStreamReader(stream); - BufferedReader reader = new BufferedReader(isr); - return Integer.parseInt(reader.readLine()); + BufferedReader reader = new BufferedReader(isr)) { + return Integer.parseInt(reader.readLine().trim()); + } } diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index 0a13fa5346..8c78abe3c5 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -39,6 +39,7 @@ import processing.app.* import processing.app.api.Contributions.ExamplesList.Companion.listAllExamples import processing.app.api.Sketch.Companion.Sketch import processing.app.ui.theme.* +import java.awt.GraphicsEnvironment import java.io.File import kotlin.io.path.Path import kotlin.io.path.exists @@ -554,10 +555,14 @@ fun Sketch.card(onOpen: () -> Unit = {}) { } fun noBaseWarning() { - Messages.showWarning( - "No Base", - "No Base instance provided, this ui is likely being previewed." - ) + if (Base.isCommandLine() || GraphicsEnvironment.isHeadless()) { + System.err.println("No Base instance provided, this ui is likely being previewed"); + } else { + Messages.showWarning( + "No Base", + "No Base instance provided, this ui is likely being previewed." + ) + } } val size = DpSize(970.dp, 600.dp) diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt index f725a999b5..8aef0bbc91 100644 --- a/app/src/processing/app/ui/theme/Window.kt +++ b/app/src/processing/app/ui/theme/Window.kt @@ -1,13 +1,12 @@ package processing.app.ui.theme import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposeWindow @@ -21,9 +20,7 @@ import androidx.compose.ui.window.rememberWindowState import com.formdev.flatlaf.util.SystemInfo import processing.app.ui.Toolkit import java.awt.Dimension - import javax.swing.JFrame -import javax.swing.JRootPane import kotlin.reflect.KClass val LocalWindow = compositionLocalOf { error("No Window Set") } @@ -120,7 +117,7 @@ private fun PDEWindowContent( window.rootPane.putClientProperty("apple.awt.transparentTitleBar", mac && fullWindowContent) Toolkit.setIcon(window) } - if(unique != null && windows.contains(unique) && windows[unique] != null){ + if (unique != null && windows.contains(unique) && windows[unique] != null && windows[unique] != window) { windows[unique]?.toFront() window.dispose() return diff --git a/app/test/processing/app/PDEWelcomeTest.kt b/app/test/processing/app/PDEWelcomeTest.kt new file mode 100644 index 0000000000..d09b0f6fa7 --- /dev/null +++ b/app/test/processing/app/PDEWelcomeTest.kt @@ -0,0 +1,258 @@ +package processing.app.ui + +import androidx.compose.ui.test.* +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import processing.app.Base +import processing.app.ui.theme.PDETheme +import processing.app.api.Sketch.Companion.Sketch + +@OptIn(ExperimentalTestApi::class) +class PDEWelcomeTest { + + // Critical Function Tests + + @Test + fun testWelcomeScreenRendersWithoutBase() = runComposeUiTest { + setContent { + PDETheme { PDEWelcome(base = null) } + } + waitForIdle() + } + + @Test + fun testWelcomeScreenRendersWithBase() = runComposeUiTest { + val base: Base = mock() + setContent { + PDETheme { PDEWelcome(base = base) } + } + waitForIdle() + } + + + // Action button visibility + + @Test + fun testNewSketchButtonIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText(Labels.NEW_SKETCH, substring = true).assertIsDisplayed() + } + + @Test + fun testSketchbookButtonIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText(Labels.SKETCHBOOK, substring = true).assertIsDisplayed() + } + + @Test + fun testExamplesButtonIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText(Labels.EXAMPLES, substring = true).assertIsDisplayed() + } + + // Action button clicks + + @Test + fun testNewSketchButtonCallsHandleNew() = runComposeUiTest { + val base: Base = mock() + setContent { PDETheme { PDEWelcome(base = base) } } + onNodeWithText(Labels.NEW_SKETCH, substring = true).performClick() + verify(base).handleNew() + } + + @Test + fun testSketchbookButtonCallsShowSketchbookFrame() = runComposeUiTest { + val base: Base = mock() + setContent { PDETheme { PDEWelcome(base = base) } } + onNodeWithText(Labels.SKETCHBOOK, substring = true).performClick() + verify(base).showSketchbookFrame() + } + + @Test + fun debugSemanticTree() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + waitForIdle() + onRoot().printToLog("PDEWelcome") + } + + @Test + fun testExamplesButtonCallsShowExamplesFrame() = runComposeUiTest { + val base: Base = mock() + setContent { PDETheme { PDEWelcome(base = base) } } + onNodeWithText(Labels.EXAMPLES, substring = true).performClick() + verify(base).showExamplesFrame() + } + + // Null-base safety + + @Test + fun testNewSketchWithNullBaseDoesNotCrash() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = null) } } + onNodeWithText(Labels.NEW_SKETCH, substring = true).performClick() + waitForIdle() + } + + @Test + fun testSketchbookWithNullBaseDoesNotCrash() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = null) } } + onNodeWithText(Labels.SKETCHBOOK, substring = true).performClick() + waitForIdle() + } + + @Test + fun testExamplesWithNullBaseDoesNotCrash() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = null) } } + onNodeWithText(Labels.EXAMPLES, substring = true).performClick() + waitForIdle() + } + + @Test + fun testNoBaseMethodsCalledWhenBaseIsNull() = runComposeUiTest { + val base: Base = mock() + setContent { PDETheme { PDEWelcome(base = null) } } + onNodeWithText(Labels.NEW_SKETCH, substring = true).performClick() + onNodeWithText(Labels.SKETCHBOOK, substring = true).performClick() + onNodeWithText(Labels.EXAMPLES, substring = true).performClick() + verifyNoInteractions(base) + } + + // Show on startup checkbox + + @Test + fun testShowOnStartupCheckboxIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText(Labels.SHOW_ON_STARTUP, substring = true).assertIsDisplayed() + } + + @Test + fun testShowOnStartupCheckboxTogglesPreference() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText(Labels.SHOW_ON_STARTUP, substring = true).performClick() + waitForIdle() + // Row must still be present after toggling + onNodeWithText(Labels.SHOW_ON_STARTUP, substring = true).assertIsDisplayed() + } + + // Resource & community links + + @Test + fun testGetStartedLinkIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText(Labels.GET_STARTED, substring = true).assertIsDisplayed() + } + + @Test + fun testTutorialsLinkIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText(Labels.TUTORIALS, substring = true).assertIsDisplayed() + } + + @Test + fun testDocumentationLinkIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText(Labels.DOCUMENTATION, substring = true).assertIsDisplayed() + } + + @Test + fun testForumLinkIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText(Labels.FORUM, substring = true).assertIsDisplayed() + } + + @Test + fun testDiscordLinkIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText("Discord", substring = true).assertIsDisplayed() + } + + @Test + fun testGithubLinkIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText("GitHub", substring = true).assertIsDisplayed() + } + + @Test + fun testInstagramLinkIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + onNodeWithText("Instagram", substring = true).assertIsDisplayed() + } + + // Examples list + + @Test + fun testExamplesListIsDisplayed() = runComposeUiTest { + setContent { PDETheme { PDEWelcome(base = mock()) } } + waitForIdle() + onAllNodesWithText(Labels.OPEN_SKETCH, substring = true) + .onFirst() + .assertExists() + } + + @Test + fun testExamplesListFallsBackToDefaultsWhenNoSketches() = runComposeUiTest { + // When listAllExamples() yields nothing, PDEWelcome falls back to the + // 4 hard-coded sketches. Either way at least one card must exist. + setContent { PDETheme { PDEWelcome(base = mock()) } } + waitForIdle() + onAllNodesWithText(Labels.OPEN_SKETCH, substring = true) + .onFirst() + .assertExists() + } + + @Test + fun testSketchCardOpenButtonTriggersCallback() = runComposeUiTest { + var opened = false + setContent { + PDETheme { + val sketch = Sketch(path = "/tmp", name = "test") + sketch.card(onOpen = { opened = true }) + } + } + // Hover to reveal the overlay + onRoot().performMouseInput { moveTo(center) } + waitForIdle() + onNodeWithText(Labels.OPEN_SKETCH, substring = true).performClick() + assert(opened) + } + + @Test + fun testSketchCardHoverRevealsBanner() = runComposeUiTest { + setContent { + PDETheme { + val sketch = Sketch(path = "/tmp", name = "MySketch") + sketch.card() + } + } + onRoot().performMouseInput { moveTo(center) } + waitForIdle() + onNodeWithText("MySketch", substring = true).assertIsDisplayed() + } + + @Test + fun testPDEWelcomeWithSurveyRendersWithoutCrash() = runComposeUiTest { + setContent { PDETheme { PDEWelcomeWithSurvey(base = mock()) } } + waitForIdle() + } + + @Test + fun testPDEWelcomeWithSurveyRendersWithNullBase() = runComposeUiTest { + setContent { PDETheme { PDEWelcomeWithSurvey(base = null) } } + waitForIdle() + } + + // Label constants. Update if anything is changed + + private object Labels { + const val NEW_SKETCH = "New Sketch" + const val SKETCHBOOK = "My Sketches" // was "Sketchbook" + const val EXAMPLES = "Open Examples" // was "Examples" + const val SHOW_ON_STARTUP = "Show this window at startup" // was "Show on startup" + const val GET_STARTED = "Get Started" + const val TUTORIALS = "Tutorials" + const val DOCUMENTATION = "Reference" // was "Documentation" + const val FORUM = "Forum" + const val OPEN_SKETCH = "Open" + } +} diff --git a/app/test/processing/app/UpdateCheckTest.java b/app/test/processing/app/UpdateCheckTest.java new file mode 100644 index 0000000000..9dbc214b9e --- /dev/null +++ b/app/test/processing/app/UpdateCheckTest.java @@ -0,0 +1,153 @@ +package processing.app; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class UpdateCheckTest { + + @TempDir + Path tempDir; + + // Helper: write content to a temp file and return its URL string + private String createTempFile(String content) throws IOException { + Path file = tempDir.resolve("test.txt"); + Files.writeString(file, content, StandardCharsets.UTF_8); + return file.toUri().toString(); + } + + + // tests to show that the method returns what it should + @Test + void readInt_simpleInteger_returnsCorrectValue() throws IOException { + String url = createTempFile("42\n"); + assertEquals(42, UpdateCheck.readInt(url)); + } + + @Test + void readInt_negativeInteger_returnsCorrectValue() throws IOException { + String url = createTempFile("-7\n"); + assertEquals(-7, UpdateCheck.readInt(url)); + } + + @Test + void readInt_integerWithLeadingAndTrailingWhitespace_returnsCorrectValue() throws IOException { + String url = createTempFile(" 100 \n"); + assertEquals(100, UpdateCheck.readInt(url)); + } + + @Test + void readInt_integerWithNoNewline_returnsCorrectValue() throws IOException { + String url = createTempFile("99"); + assertEquals(99, UpdateCheck.readInt(url)); + } + + @Test + void readInt_maxInt_returnsCorrectValue() throws IOException { + String url = createTempFile(String.valueOf(Integer.MAX_VALUE)); + assertEquals(Integer.MAX_VALUE, UpdateCheck.readInt(url)); + } + + @Test + void readInt_minInt_returnsCorrectValue() throws IOException { + String url = createTempFile(String.valueOf(Integer.MIN_VALUE)); + assertEquals(Integer.MIN_VALUE, UpdateCheck.readInt(url)); + } + + @Test + void readInt_integerWithMultipleLines_readsOnlyFirstLine() throws IOException { + String url = createTempFile("5\n10\n15"); + assertEquals(5, UpdateCheck.readInt(url)); + } + + // checks for if errors are correctly reported + @Test + void readInt_nonNumericContent_throwsNumberFormatException() throws IOException { + String url = createTempFile("not-a-number\n"); + assertThrows(NumberFormatException.class, () -> UpdateCheck.readInt(url)); + } + + @Test + void readInt_emptyFile_throwsNullPointerException() throws IOException { + String url = createTempFile(""); + // readLine() returns null on empty stream → trim() throws NPE + assertThrows(Exception.class, () -> UpdateCheck.readInt(url)); + } + + @Test + void readInt_blankLine_throwsNumberFormatException() throws IOException { + String url = createTempFile(" \n"); + assertThrows(NumberFormatException.class, () -> UpdateCheck.readInt(url)); + } + + @Test + void readInt_floatValue_throwsNumberFormatException() throws IOException { + String url = createTempFile("3.14\n"); + assertThrows(NumberFormatException.class, () -> UpdateCheck.readInt(url)); + } + + @Test + void readInt_overflowValue_throwsNumberFormatException() throws IOException { + String url = createTempFile("99999999999999\n"); + assertThrows(NumberFormatException.class, () -> UpdateCheck.readInt(url)); + } + + @Test + void readInt_invalidUrl_throwsMalformedURLException() { + assertThrows(MalformedURLException.class, + () -> UpdateCheck.readInt("not-a-valid-url")); + } + + @Test + void readInt_nonExistentFile_throwsIOException() { + String nonExistent = tempDir.resolve("ghost.txt").toUri().toString(); + assertThrows(IOException.class, () -> UpdateCheck.readInt(nonExistent)); + } + + // checks for if streams are closed + @Test + void readInt_streamIsClosedAfterSuccessfulRead() throws IOException { + // Spy on the InputStream to verify close() is called + Path file = tempDir.resolve("close_test.txt"); + Files.writeString(file, "7", StandardCharsets.UTF_8); + + URL url = file.toUri().toURL(); + InputStream realStream = url.openStream(); + InputStream spyStream = spy(realStream); + + try (MockedConstruction mockedUrl = mockConstruction(URL.class, + (mock, ctx) -> when(mock.openStream()).thenReturn(spyStream))) { + + UpdateCheck.readInt(file.toUri().toString()); + } + + verify(spyStream, atLeastOnce()).close(); + } + + @Test + void readInt_streamIsClosedEvenWhenParseThrows() throws IOException { + Path file = tempDir.resolve("bad_close_test.txt"); + Files.writeString(file, "not-a-number", StandardCharsets.UTF_8); + + URL url = file.toUri().toURL(); + InputStream realStream = url.openStream(); + InputStream spyStream = spy(realStream); + + try (MockedConstruction mockedUrl = mockConstruction(URL.class, + (mock, ctx) -> when(mock.openStream()).thenReturn(spyStream))) { + + assertThrows(NumberFormatException.class, + () -> UpdateCheck.readInt(file.toUri().toString())); + } + + verify(spyStream, atLeastOnce()).close(); + } +} diff --git a/app/utils/build.gradle.kts b/app/utils/build.gradle.kts index 1618e1706b..812c37959e 100644 --- a/app/utils/build.gradle.kts +++ b/app/utils/build.gradle.kts @@ -10,6 +10,7 @@ repositories { dependencies { testImplementation(platform("org.junit:junit-bom:5.10.0")) testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } publishing{ @@ -23,4 +24,4 @@ publishing{ tasks.test { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/build.gradle.kts b/build.gradle.kts index 371e34bc29..ca163e476c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,35 @@ plugins { // Can be deleted after the migration to Gradle is complete layout.buildDirectory = file(".build") +val enableWebGPU = findProperty("enableWebGPU")?.toString()?.toBoolean() ?: false +val javaVersion = if (enableWebGPU) "25" else "17" +val kotlinJvmTarget = if (enableWebGPU) { + org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25 +} else { + org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 +} + +allprojects { + tasks.withType().configureEach { + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + } + + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(kotlinJvmTarget) + } + } + + plugins.withType { + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(javaVersion.toInt())) + } + } + } +} + // Configure the dependencyUpdates task tasks { dependencyUpdates { diff --git a/build/linux/desktop.template b/build/linux/desktop.template index a3e4373c0b..d275ab9148 100644 --- a/build/linux/desktop.template +++ b/build/linux/desktop.template @@ -9,4 +9,4 @@ Terminal=false Categories=Development;IDE;Programming; MimeType=text/x-processing;x-scheme-handler/pde; Keywords=sketching;software;animation;programming;coding; -StartupWMClass=processing-app-ui-Splash +StartupWMClass=processing-app-ProcessingKt diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000000..876c922b22 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} diff --git a/buildSrc/src/main/kotlin/processing/gradle/CargoBuildTask.kt b/buildSrc/src/main/kotlin/processing/gradle/CargoBuildTask.kt new file mode 100644 index 0000000000..4cbc328478 --- /dev/null +++ b/buildSrc/src/main/kotlin/processing/gradle/CargoBuildTask.kt @@ -0,0 +1,66 @@ +package processing.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.* +import org.gradle.process.ExecOperations +import javax.inject.Inject + +abstract class CargoBuildTask : DefaultTask() { + + @get:Inject + abstract val execOperations: ExecOperations + + @get:InputDirectory + abstract val cargoWorkspaceDir: DirectoryProperty + + @get:Input + abstract val manifestPath: Property + + @get:Input + abstract val release: Property + + @get:Input + abstract val cargoPath: Property + + @get:Input + abstract val features: ListProperty + + @get:OutputFile + abstract val outputLibrary: RegularFileProperty + + init { + group = "rust" + description = "Builds Rust library using cargo" + + // release by default + release.convention(true) + features.convention(emptyList()) + } + + @TaskAction + fun build() { + val buildType = if (release.get()) "release" else "debug" + logger.lifecycle("Building Rust library ($buildType mode)...") + + val args = mutableListOf("build") + if (release.get()) { + args.add("--release") + } + if (features.get().isNotEmpty()) { + args.add("--features") + args.add(features.get().joinToString(",")) + } + args.add("--manifest-path") + args.add(manifestPath.get()) + + + execOperations.exec { + workingDir = cargoWorkspaceDir.get().asFile + commandLine = listOf(cargoPath.get()) + args + } + } +} diff --git a/buildSrc/src/main/kotlin/processing/gradle/CargoCleanTask.kt b/buildSrc/src/main/kotlin/processing/gradle/CargoCleanTask.kt new file mode 100644 index 0000000000..1caf9133bf --- /dev/null +++ b/buildSrc/src/main/kotlin/processing/gradle/CargoCleanTask.kt @@ -0,0 +1,38 @@ +package processing.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.* +import org.gradle.process.ExecOperations +import javax.inject.Inject + +abstract class CargoCleanTask : DefaultTask() { + + @get:Inject + abstract val execOperations: ExecOperations + + @get:InputDirectory + abstract val cargoWorkspaceDir: DirectoryProperty + + @get:Input + abstract val manifestPath: Property + + @get:Input + abstract val cargoPath: Property + + init { + group = "rust" + description = "Cleans Rust build artifacts" + } + + @TaskAction + fun clean() { + logger.lifecycle("Cleaning Rust build artifacts...") + + execOperations.exec { + workingDir = cargoWorkspaceDir.get().asFile + commandLine(cargoPath.get(), "clean", "--manifest-path", manifestPath.get()) + } + } +} diff --git a/buildSrc/src/main/kotlin/processing/gradle/DownloadJextractTask.kt b/buildSrc/src/main/kotlin/processing/gradle/DownloadJextractTask.kt new file mode 100644 index 0000000000..254195a38d --- /dev/null +++ b/buildSrc/src/main/kotlin/processing/gradle/DownloadJextractTask.kt @@ -0,0 +1,60 @@ +package processing.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.* +import java.net.URI + +abstract class DownloadJextractTask : DefaultTask() { + + @get:Input + abstract val jextractVersion: Property + + @get:Input + abstract val platform: Property + + @get:OutputDirectory + abstract val jextractDir: DirectoryProperty + + @get:Internal + abstract val downloadTarball: RegularFileProperty + + init { + group = "rust" + description = "Downloads and extracts jextract for the current platform" + } + + @TaskAction + fun download() { + val version = jextractVersion.get() + val plat = platform.get() + val fileName = "openjdk-$version" + "_${plat}_bin.tar.gz" + val downloadUrl = "https://download.java.net/java/early_access/jextract/22/6/$fileName" + val tarFile = downloadTarball.get().asFile + + if (!tarFile.exists()) { + logger.lifecycle("Downloading jextract from $downloadUrl") + try { + tarFile.outputStream().use { output -> + URI.create(downloadUrl).toURL().openStream().use { input -> + input.copyTo(output) + } + } + } catch (e: Exception) { + throw GradleException("Failed to download jextract: ${e.message}", e) + } + } + + val extractDir = jextractDir.get().asFile + logger.lifecycle("Extracting jextract to ${extractDir.parent}") + project.copy { + from(project.tarTree(tarFile)) + into(extractDir.parent) + } + + logger.lifecycle("jextract extracted to: $extractDir") + } +} diff --git a/buildSrc/src/main/kotlin/processing/gradle/GenerateJextractBindingsTask.kt b/buildSrc/src/main/kotlin/processing/gradle/GenerateJextractBindingsTask.kt new file mode 100644 index 0000000000..22fc9c240c --- /dev/null +++ b/buildSrc/src/main/kotlin/processing/gradle/GenerateJextractBindingsTask.kt @@ -0,0 +1,49 @@ +package processing.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.* +import org.gradle.process.ExecOperations +import javax.inject.Inject + +abstract class GenerateJextractBindingsTask : DefaultTask() { + + @get:Inject + abstract val execOperations: ExecOperations + + @get:InputFile + abstract val headerFile: RegularFileProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @get:Input + abstract val targetPackage: Property + + @get:Input + abstract val jextractPath: Property + + init { + group = "rust" + description = "Generates Java Panama FFM bindings from C headers" + } + + @TaskAction + fun generate() { + val outDir = outputDirectory.get().asFile + outDir.mkdirs() + + logger.lifecycle("Generating Java bindings from ${headerFile.get().asFile}...") + + execOperations.exec { + commandLine( + jextractPath.get(), + "--output", outDir.absolutePath, + "--target-package", targetPackage.get(), + headerFile.get().asFile.absolutePath + ) + } + } +} diff --git a/buildSrc/src/main/kotlin/processing/gradle/JextractUtils.kt b/buildSrc/src/main/kotlin/processing/gradle/JextractUtils.kt new file mode 100644 index 0000000000..505a222a59 --- /dev/null +++ b/buildSrc/src/main/kotlin/processing/gradle/JextractUtils.kt @@ -0,0 +1,29 @@ +package processing.gradle + +object JextractUtils { + fun findUserJextract(): String? { + val jextractHome = System.getenv("JEXTRACT_HOME") ?: return null + + val isWindows = System.getProperty("os.name").lowercase().contains("windows") + val path = if (isWindows) { + "$jextractHome/bin/jextract.bat" + } else { + "$jextractHome/bin/jextract" + } + + val file = java.io.File(path) + if (file.exists()) { + return path + } + + return null + } + + fun getExecutableName(): String { + return if (System.getProperty("os.name").lowercase().contains("windows")) { + "jextract.bat" + } else { + "jextract" + } + } +} diff --git a/buildSrc/src/main/kotlin/processing/gradle/PlatformUtils.kt b/buildSrc/src/main/kotlin/processing/gradle/PlatformUtils.kt new file mode 100644 index 0000000000..f442dba985 --- /dev/null +++ b/buildSrc/src/main/kotlin/processing/gradle/PlatformUtils.kt @@ -0,0 +1,53 @@ +package processing.gradle + +import org.gradle.api.GradleException + +object PlatformUtils { + data class Platform( + val os: String, + val arch: String, + val libExtension: String, + val target: String + ) { + val libName: String + get() = if (os == "windows") "processing.$libExtension" else "libprocessing.$libExtension" + + val jextractPlatform: String + get() { + val jextractArch = if (arch == "x86_64") "x64" else arch + return "$os-$jextractArch" + } + } + + fun detect(): Platform { + val osName = System.getProperty("os.name").lowercase() + val osArch = System.getProperty("os.arch").lowercase() + + val os = when { + osName.contains("mac") || osName.contains("darwin") -> "macos" + osName.contains("win") -> "windows" + osName.contains("linux") -> "linux" + else -> throw GradleException("Unsupported OS: $osName") + } + + val arch = when { + osArch.contains("aarch64") || osArch.contains("arm") -> "aarch64" + osArch.contains("x86_64") || osArch.contains("amd64") -> "x86_64" + else -> throw GradleException("Unsupported architecture: $osArch") + } + + val libExtension = when (os) { + "macos" -> "dylib" + "windows" -> "dll" + "linux" -> "so" + else -> throw GradleException("Unknown platform: $os") + } + + return Platform(os, arch, libExtension, "$os-$arch") + } + + fun getCargoPath(): String { + return System.getenv("CARGO_HOME")?.let { "$it/bin/cargo" } + ?: "${System.getProperty("user.home")}/.cargo/bin/cargo" + } +} diff --git a/buildSrc/src/main/kotlin/processing/gradle/SignResourcesTask.kt b/buildSrc/src/main/kotlin/processing/gradle/SignResourcesTask.kt new file mode 100644 index 0000000000..91be783ea3 --- /dev/null +++ b/buildSrc/src/main/kotlin/processing/gradle/SignResourcesTask.kt @@ -0,0 +1,108 @@ +package processing.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import javax.inject.Inject + +abstract class SignResourcesTask : DefaultTask() { + + @get:Inject + abstract val execOperations: ExecOperations + + @get:InputDirectory + abstract val resourcesPath: DirectoryProperty + + init { + group = "compose desktop" + description = "Signs macOS resources (binaries and libraries) for distribution" + } + + @TaskAction + fun signResources() { + val resourcesDir = resourcesPath.get().asFile + val jars = mutableListOf() + + // Copy Info.plist if present + project.fileTree(resourcesDir) + .matching { include("**/Info.plist") } + .singleOrNull() + ?.let { file -> + project.copy { + from(file) + into(resourcesDir) + } + } + + // Extract JARs to temporary directories for signing + project.fileTree(resourcesDir) { + include("**/*.jar") + exclude("**/*.jar.tmp/**") + }.forEach { file -> + val tempDir = file.parentFile.resolve("${file.name}.tmp") + project.copy { + from(project.zipTree(file)) + into(tempDir) + } + file.delete() + jars.add(tempDir) + } + + // Sign all binaries and native libraries + project.fileTree(resourcesDir) { + include("**/bin/**") + include("**/*.jnilib") + include("**/*.dylib") + include("**/*aarch64*") + include("**/*x86_64*") + include("**/*ffmpeg*") + include("**/ffmpeg*/**") + exclude("jdk/**") + exclude("*.jar") + exclude("*.so") + exclude("*.dll") + }.forEach { file -> + execOperations.exec { + commandLine( + "codesign", + "--timestamp", + "--force", + "--deep", + "--options=runtime", + "--sign", + "Developer ID Application", + file + ) + } + } + + // Repackage JARs after signing + jars.forEach { file -> + FileOutputStream(File(file.parentFile, file.nameWithoutExtension)).use { fos -> + ZipOutputStream(fos).use { zos -> + file.walkTopDown().forEach { fileEntry -> + if (fileEntry.isFile) { + val zipEntryPath = fileEntry.relativeTo(file).path + val entry = ZipEntry(zipEntryPath) + zos.putNextEntry(entry) + fileEntry.inputStream().use { input -> + input.copyTo(zos) + } + zos.closeEntry() + } + } + } + } + file.deleteRecursively() + } + + // Clean up Info.plist + File(resourcesDir, "Info.plist").delete() + } +} diff --git a/contributors.png b/contributors.png new file mode 100644 index 0000000000..2f252b0b17 Binary files /dev/null and b/contributors.png differ diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 16593450ec..19b42773a8 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,4 +1,5 @@ import com.vanniktech.maven.publish.SonatypeHost +import processing.gradle.* plugins { id("java") @@ -11,11 +12,17 @@ repositories { maven { url = uri("https://jogamp.org/deployment/maven") } } +val enableWebGPU = findProperty("enableWebGPU")?.toString()?.toBoolean() ?: false + sourceSets{ main{ java{ srcDirs("src") exclude("**/*.jnilib") + if (!enableWebGPU) { + exclude("processing/webgpu/**") + exclude("processing/ffi/**") + } } resources{ srcDirs("src") @@ -25,6 +32,9 @@ sourceSets{ test{ java{ srcDirs("test") + if (!enableWebGPU) { + exclude("processing/webgpu/**") + } } } } @@ -33,9 +43,172 @@ dependencies { implementation(libs.jogl) implementation(libs.gluegen) + if (enableWebGPU) { + val lwjglVersion = "3.3.6" + val lwjglNatives = when { + System.getProperty("os.name").lowercase().contains("mac") -> { + if (System.getProperty("os.arch").contains("aarch64")) { + "natives-macos-arm64" + } else { + "natives-macos" + } + } + System.getProperty("os.name").lowercase().contains("win") -> "natives-windows" + System.getProperty("os.name").lowercase().contains("linux") -> "natives-linux" + else -> "natives-linux" + } + + implementation(platform("org.lwjgl:lwjgl-bom:$lwjglVersion")) + implementation("org.lwjgl", "lwjgl") + implementation("org.lwjgl", "lwjgl-glfw") + runtimeOnly("org.lwjgl", "lwjgl", classifier = lwjglNatives) + runtimeOnly("org.lwjgl", "lwjgl-glfw", classifier = lwjglNatives) + } + testImplementation(libs.junit) } +if (enableWebGPU) { + val currentPlatform = PlatformUtils.detect() + val libprocessingDir = file("${project.rootDir}/libprocessing") + + if (!libprocessingDir.exists()) { + throw GradleException( + "libprocessing submodule directory not found at: ${libprocessingDir.absolutePath}\n" + + "Please initialize the submodule with: git submodule update --init --recursive" + ) + } + + val rustTargetDir = file("$libprocessingDir/target") + val nativeOutputDir = file("${layout.buildDirectory.get()}/native/${currentPlatform.target}") + + val ffiManifestPath = fileTree(libprocessingDir) { + include("**/processing_ffi/Cargo.toml") + }.files.firstOrNull()?.let { it.relativeTo(libprocessingDir).path } + ?: throw GradleException( + "Could not find processing_ffi Cargo.toml in libprocessing.\n" + + "Searched in: ${libprocessingDir.absolutePath}\n" + + "The libprocessing structure may have changed." + ) + + val buildRustRelease by tasks.registering(CargoBuildTask::class) { + cargoWorkspaceDir.set(libprocessingDir) + manifestPath.set(ffiManifestPath) + release.set(true) + cargoPath.set(PlatformUtils.getCargoPath()) + if (currentPlatform.os == "linux") { + features.set(listOf("x11", "wayland")) + } + outputLibrary.set(file("$rustTargetDir/release/${currentPlatform.libName}")) + + inputs.files(fileTree("$libprocessingDir/crates") { + include("**/src/**/*.rs") + include("**/Cargo.toml") + include("**/build.rs") + include("**/cbindgen.toml") + }) + inputs.file("$libprocessingDir/Cargo.toml") + inputs.file("$libprocessingDir/Cargo.lock") + + val headerDir = file("$libprocessingDir/${ffiManifestPath}").parentFile.resolve("include") + outputs.file("$headerDir/processing.h") + } + + val copyNativeLibs by tasks.registering(Copy::class) { + group = "rust" + description = "Copy processing library to build directory" + + dependsOn(buildRustRelease) + + from("$rustTargetDir/release") { + include(currentPlatform.libName) + } + + into(nativeOutputDir) + } + + val bundleNativeLibs by tasks.registering(Copy::class) { + group = "rust" + description = "Bundle native library into resources" + + dependsOn(copyNativeLibs) + + from(nativeOutputDir) + into("${sourceSets.main.get().output.resourcesDir}/native/${currentPlatform.target}") + } + + val cleanRust by tasks.registering(CargoCleanTask::class) { + cargoWorkspaceDir.set(libprocessingDir) + manifestPath.set(ffiManifestPath) + cargoPath.set(PlatformUtils.getCargoPath()) + + mustRunAfter(buildRustRelease) + } + + tasks.named("clean") { + dependsOn(cleanRust) + } + + val generatedJavaDir = file("${layout.buildDirectory.get()}/generated/sources/jextract/java") + + sourceSets.main { + java.srcDirs(generatedJavaDir) + } + + val jextractVersionString = "22-jextract+6-47" + val jextractDirectory = file("${gradle.gradleUserHomeDir}/jextract-22") + val jextractTarballFile = file("${gradle.gradleUserHomeDir}/jextract-$jextractVersionString.tar.gz") + + val downloadJextract by tasks.registering(DownloadJextractTask::class) { + jextractVersion.set(jextractVersionString) + platform.set(currentPlatform.jextractPlatform) + jextractDir.set(jextractDirectory) + downloadTarball.set(jextractTarballFile) + + onlyIf { !jextractDirectory.exists() } + } + + val makeJextractExecutable by tasks.registering(Exec::class) { + group = "rust" + description = "Make jextract binary executable on Unix systems" + + dependsOn(downloadJextract) + onlyIf { !System.getProperty("os.name").lowercase().contains("windows") } + + val jextractBin = file("$jextractDirectory/bin/jextract") + commandLine("chmod", "+x", jextractBin.absolutePath) + } + + val generateJavaBindings by tasks.registering(GenerateJextractBindingsTask::class) { + dependsOn(buildRustRelease) + + val userJextract = JextractUtils.findUserJextract() + if (userJextract == null) { + dependsOn(downloadJextract, makeJextractExecutable) + } + + // Find header file dynamically based on FFI manifest location + val headerDir = file("$libprocessingDir/${ffiManifestPath}").parentFile.resolve("include") + headerFile.set(file("$headerDir/processing.h")) + outputDirectory.set(generatedJavaDir) + targetPackage.set("processing.ffi") + + jextractPath.set(userJextract ?: "$jextractDirectory/bin/${JextractUtils.getExecutableName()}") + } + + tasks.named("compileJava") { + dependsOn(generateJavaBindings) + } + + tasks.named("compileKotlin") { + dependsOn(generateJavaBindings) + } + + tasks.named("processResources") { + dependsOn(bundleNativeLibs) + } +} + mavenPublishing{ publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) signAllPublications() diff --git a/core/examples/src/main/java/webgpu/AnimatedMesh.java b/core/examples/src/main/java/webgpu/AnimatedMesh.java new file mode 100644 index 0000000000..c1dec3f049 --- /dev/null +++ b/core/examples/src/main/java/webgpu/AnimatedMesh.java @@ -0,0 +1,63 @@ +package webgpu; + +import processing.core.PApplet; + +public class AnimatedMesh extends PApplet { + + int gridSize = 20; + float spacing = 10; + float time = 0; + + public void settings() { + size(600, 600, WEBGPU); + } + + public void setup() { + perspective(PI/3, (float)width/height, 0.1f, 1000); + camera(150, 150, 150, 0, 0, 0, 0, 1, 0); + } + + public void draw() { + background(13, 13, 26); + + float offset = (gridSize * spacing) / 2.0f; + + beginShape(TRIANGLES); + for (int z = 0; z < gridSize - 1; z++) { + for (int x = 0; x < gridSize - 1; x++) { + float px0 = x * spacing - offset; + float pz0 = z * spacing - offset; + float px1 = (x + 1) * spacing - offset; + float pz1 = (z + 1) * spacing - offset; + + float y00 = wave(px0, pz0); + float y10 = wave(px1, pz0); + float y01 = wave(px0, pz1); + float y11 = wave(px1, pz1); + + fill(x * 255.0f / gridSize, 128, z * 255.0f / gridSize); + normal(0, 1, 0); + + vertex(px0, y00, pz0); + vertex(px0, y01, pz1); + vertex(px1, y10, pz0); + + vertex(px1, y10, pz0); + vertex(px0, y01, pz1); + vertex(px1, y11, pz1); + } + } + endShape(); + + time += 0.05f; + } + + float wave(float x, float z) { + return sin(x * 0.1f + time) * cos(z * 0.1f + time) * 20; + } + + public static void main(String[] args) { + PApplet.disableAWT = true; + PApplet.main(AnimatedMesh.class.getName()); + } +} diff --git a/core/examples/src/main/java/webgpu/BackgroundImage.java b/core/examples/src/main/java/webgpu/BackgroundImage.java new file mode 100644 index 0000000000..d5386ae9c4 --- /dev/null +++ b/core/examples/src/main/java/webgpu/BackgroundImage.java @@ -0,0 +1,36 @@ +package webgpu; + +import processing.core.PApplet; +import processing.core.PImage; + +public class BackgroundImage extends PApplet { + + PImage img; + + public void settings() { + size(400, 400, WEBGPU); + } + + public void setup() { + img = createImage(400, 400, RGB); + img.loadPixels(); + for (int y = 0; y < img.height; y++) { + for (int x = 0; x < img.width; x++) { + int r = (int) (x * 255.0 / img.width); + int g = (int) (y * 255.0 / img.height); + int b = 128; + img.pixels[y * img.width + x] = color(r, g, b); + } + } + img.updatePixels(); + } + + public void draw() { + background(img); + } + + public static void main(String[] args) { + PApplet.disableAWT = true; + PApplet.main(BackgroundImage.class.getName()); + } +} diff --git a/core/examples/src/main/java/webgpu/Box3D.java b/core/examples/src/main/java/webgpu/Box3D.java new file mode 100644 index 0000000000..2f24b9a93c --- /dev/null +++ b/core/examples/src/main/java/webgpu/Box3D.java @@ -0,0 +1,35 @@ +package webgpu; + +import processing.core.PApplet; + +public class Box3D extends PApplet { + + float angle = 0; + + public void settings() { + size(400, 400, WEBGPU); + } + + public void setup() { + perspective(PI/3, (float)width/height, 0.1f, 1000); + camera(200, 200, 300, 0, 0, 0, 0, 1, 0); + } + + public void draw() { + background(26, 26, 38); + + pushMatrix(); + rotateY(angle); + rotateX(angle * 0.7f); + fill(200, 100, 100); + box(100); + popMatrix(); + + angle += 0.02f; + } + + public static void main(String[] args) { + PApplet.disableAWT = true; + PApplet.main(Box3D.class.getName()); + } +} diff --git a/core/examples/src/main/java/webgpu/Rectangle.java b/core/examples/src/main/java/webgpu/Rectangle.java new file mode 100644 index 0000000000..5a9b2c61d1 --- /dev/null +++ b/core/examples/src/main/java/webgpu/Rectangle.java @@ -0,0 +1,23 @@ +package webgpu; + +import processing.core.PApplet; + +public class Rectangle extends PApplet { + + public void settings() { + size(400, 400, WEBGPU); + } + + public void draw() { + background(51); + + fill(255); + noStroke(); + rect(10, 10, 100, 100); + } + + public static void main(String[] args) { + PApplet.disableAWT = true; + PApplet.main(Rectangle.class.getName()); + } +} diff --git a/core/examples/src/main/java/webgpu/Transforms.java b/core/examples/src/main/java/webgpu/Transforms.java new file mode 100644 index 0000000000..781423cd62 --- /dev/null +++ b/core/examples/src/main/java/webgpu/Transforms.java @@ -0,0 +1,47 @@ +package webgpu; + +import processing.core.PApplet; + +public class Transforms extends PApplet { + + float t = 0; + + public void settings() { + size(400, 400, WEBGPU); + } + + public void draw() { + background(26); + + noStroke(); + + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + pushMatrix(); + + translate(50 + j * 100, 50 + i * 100); + + float angle = t + (i + j) * PI / 8.0f; + rotate(angle); + + float s = 0.8f + sin(t * 2.0f + (i * j)) * 0.2f; + scale(s, s); + + float r = j / 3.0f; + float g = i / 3.0f; + fill(r * 255, g * 255, 204); + + rect(-20, -20, 40, 40); + + popMatrix(); + } + } + + t += 0.02f; + } + + public static void main(String[] args) { + PApplet.disableAWT = true; + PApplet.main(Transforms.class.getName()); + } +} diff --git a/core/examples/src/main/java/webgpu/UpdatePixels.java b/core/examples/src/main/java/webgpu/UpdatePixels.java new file mode 100644 index 0000000000..9ef2cf5aba --- /dev/null +++ b/core/examples/src/main/java/webgpu/UpdatePixels.java @@ -0,0 +1,74 @@ +package webgpu; + +import processing.core.PApplet; + +public class UpdatePixels extends PApplet { + + static final int RECT_W = 10; + static final int RECT_H = 10; + + boolean firstFrame = true; + + public void settings() { + size(100, 100, WEBGPU); + } + + public void draw() { + background(0); // Black background + + loadPixels(); + + for (int y = 20; y < 20 + RECT_H; y++) { + for (int x = 20; x < 20 + RECT_W; x++) { + pixels[y * width + x] = color(255, 0, 0); + } + } + + for (int y = 60; y < 60 + RECT_H; y++) { + for (int x = 60; x < 60 + RECT_W; x++) { + pixels[y * width + x] = color(0, 0, 255); + } + } + + updatePixels(); + + if (firstFrame) { + firstFrame = false; + + println("Total pixels: " + pixels.length); + + for (int y = 0; y < height; y++) { + StringBuilder row = new StringBuilder(); + for (int x = 0; x < width; x++) { + int idx = y * width + x; + int pixel = pixels[idx]; + float r = red(pixel); + float b = blue(pixel); + float a = alpha(pixel); + + if (r > 127) { + row.append("R"); + } else if (b > 127) { + row.append("B"); + } else if (a > 127) { + row.append("."); + } else { + row.append(" "); + } + } + println(row.toString()); + } + + println("\nSample pixels:"); + println("(25, 25): " + hex(pixels[25 * width + 25])); + println("(65, 65): " + hex(pixels[65 * width + 65])); + println("(0, 0): " + hex(pixels[0])); + println("(50, 50): " + hex(pixels[50 * width + 50])); + } + } + + public static void main(String[] args) { + PApplet.disableAWT = true; + PApplet.main(UpdatePixels.class.getName()); + } +} diff --git a/core/src/processing/core/NativeLibrary.java b/core/src/processing/core/NativeLibrary.java new file mode 100644 index 0000000000..208eeedd0c --- /dev/null +++ b/core/src/processing/core/NativeLibrary.java @@ -0,0 +1,112 @@ +package processing.core; + +import java.io.*; +import java.nio.file.*; + +/** + * Handles loading of Processing's native Rust library (libprocessing). + */ +public class NativeLibrary { + private static boolean loaded = false; + private static Throwable loadError = null; + + private static final String LIBRARY_NAME = "processing"; + + // Platform + private static final String OS_NAME = System.getProperty("os.name").toLowerCase(); + private static final String OS_ARCH = System.getProperty("os.arch").toLowerCase(); + + private static final String platform; + private static final String architecture; + private static final String libraryExtension; + + static { + // platform + if (OS_NAME.contains("mac") || OS_NAME.contains("darwin")) { + platform = "macos"; + libraryExtension = "dylib"; + } else if (OS_NAME.contains("win")) { + platform = "windows"; + libraryExtension = "dll"; + } else if (OS_NAME.contains("linux")) { + platform = "linux"; + libraryExtension = "so"; + } else { + throw new UnsupportedOperationException("Unsupported OS: " + OS_NAME); + } + + // architecture + if (OS_ARCH.contains("aarch64") || OS_ARCH.contains("arm")) { + architecture = "aarch64"; + } else if (OS_ARCH.contains("x86_64") || OS_ARCH.contains("amd64")) { + architecture = "x86_64"; + } else { + throw new UnsupportedOperationException("Unsupported architecture: " + OS_ARCH); + } + + // Load the dll + try { + loadNativeLibrary(); + loaded = true; + } catch (Throwable e) { + loadError = e; + System.err.println("Warning: Failed to load Processing native library: " + e.getMessage()); + } + } + + /** + * Ensures the native library is loaded. Throws if loading failed. + */ + public static void ensureLoaded() { + if (!loaded) { + throw new RuntimeException("Native library failed to load", loadError); + } + } + + /** + * Returns whether the native library was successfully loaded. + */ + public static boolean isLoaded() { + return loaded; + } + + /** + * Returns the platform string (e.g., "macos-aarch64"). + */ + public static String getPlatform() { + return platform + "-" + architecture; + } + + /** + * Extracts and loads the native library from JAR resources. + */ + private static void loadNativeLibrary() throws IOException { + String platformTarget = platform + "-" + architecture; + String libraryFileName = platform.equals("windows") + ? LIBRARY_NAME + "." + libraryExtension + : "lib" + LIBRARY_NAME + "." + libraryExtension; + String resourcePath = "/native/" + platformTarget + "/" + libraryFileName; + + // check classloader for resource in jar + InputStream libraryStream = NativeLibrary.class.getResourceAsStream(resourcePath); + if (libraryStream == null) { + throw new FileNotFoundException( + "Native library not found in JAR: " + resourcePath + + " (platform: " + platformTarget + ")" + ); + } + + // extract + Path tempDir = Files.createTempDirectory("processing-native-"); + tempDir.toFile().deleteOnExit(); + + Path libraryPath = tempDir.resolve(libraryFileName); + Files.copy(libraryStream, libraryPath, StandardCopyOption.REPLACE_EXISTING); + libraryStream.close(); + + libraryPath.toFile().deleteOnExit(); + + // load! + System.load(libraryPath.toAbsolutePath().toString()); + } +} diff --git a/core/src/processing/core/PApplet.java b/core/src/processing/core/PApplet.java index d9df211eb7..f6a247ba54 100644 --- a/core/src/processing/core/PApplet.java +++ b/core/src/processing/core/PApplet.java @@ -2008,8 +2008,8 @@ protected PGraphics createPrimaryGraphics() { * @see PGraphics */ public PImage createImage(int w, int h, int format) { - PImage image = new PImage(w, h, format); - image.parent = this; // make save() work + PImage image = (g != null) ? g.createImage(w, h, format) : new PImage(w, h, format); + image.parent = this; return image; } @@ -9983,10 +9983,6 @@ static public void runSketch(final String[] args, } break; - case ARGS_DISABLE_AWT: - disableAWT = true; - break; - case ARGS_WINDOW_COLOR: if (value.charAt(0) == '#' && value.length() == 7) { value = value.substring(1); @@ -10038,6 +10034,10 @@ static public void runSketch(final String[] args, fullScreen = true; break; + case ARGS_DISABLE_AWT: + disableAWT = true; + break; + default: name = args[argIndex]; break label; // because of break, argIndex won't increment again diff --git a/core/src/processing/core/PConstants.java b/core/src/processing/core/PConstants.java index d21a1fa49d..d1179984ee 100644 --- a/core/src/processing/core/PConstants.java +++ b/core/src/processing/core/PConstants.java @@ -72,6 +72,8 @@ public interface PConstants { // Experimental JavaFX renderer; even better 2D performance String FX2D = "processing.javafx.PGraphicsFX2D"; + String WEBGPU = "processing.webgpu.PGraphicsWebGPU"; + String PDF = "processing.pdf.PGraphicsPDF"; String SVG = "processing.svg.PGraphicsSVG"; String DXF = "processing.dxf.RawDXF"; diff --git a/core/src/processing/core/PGraphics.java b/core/src/processing/core/PGraphics.java index 1ada6aa2ae..8d411abef2 100644 --- a/core/src/processing/core/PGraphics.java +++ b/core/src/processing/core/PGraphics.java @@ -3830,6 +3830,11 @@ private void smoothWarning(String method) { // IMAGE + public PImage createImage(int w, int h, int format) { + return new PImage(w, h, format); + } + + /** * * Modifies the location from which images are drawn by changing the way in diff --git a/core/src/processing/data/IntList.java b/core/src/processing/data/IntList.java index afb4c6cd61..83cac6b3f7 100644 --- a/core/src/processing/data/IntList.java +++ b/core/src/processing/data/IntList.java @@ -6,6 +6,7 @@ import java.util.Iterator; import java.util.Random; +import org.jetbrains.annotations.TestOnly; import processing.core.PApplet; @@ -164,13 +165,14 @@ public void clear() { * @webBrief Get an entry at a particular index */ public int get(int index) { - if (index >= this.count) { + if (index >= this.count || index < 0) { throw new ArrayIndexOutOfBoundsException(index); } return data[index]; } + /** * Set the entry at a particular index. * diff --git a/core/src/processing/data/Table.java b/core/src/processing/data/Table.java index ca04dd6ab7..066ec365a1 100644 --- a/core/src/processing/data/Table.java +++ b/core/src/processing/data/Table.java @@ -224,22 +224,27 @@ public Table(ResultSet rs) { int type = rsmd.getColumnType(col + 1); switch (type) { // TODO these aren't tested. nor are they complete. - case Types.INTEGER: - case Types.TINYINT: - case Types.SMALLINT: - setColumnType(col, INT); - break; - case Types.BIGINT: - setColumnType(col, LONG); - break; - case Types.FLOAT: - setColumnType(col, FLOAT); - break; - case Types.DECIMAL: - case Types.DOUBLE: - case Types.REAL: - setColumnType(col, DOUBLE); - break; + case Types.VARCHAR: + case Types.CHAR: + case Types.BLOB: + setColumnType(col, STRING); + break; + case Types.INTEGER: + case Types.TINYINT: + case Types.SMALLINT: + setColumnType(col, INT); + break; + case Types.BIGINT: + setColumnType(col, LONG); + break; + case Types.FLOAT: + setColumnType(col, FLOAT); + break; + case Types.DECIMAL: + case Types.DOUBLE: + case Types.REAL: + setColumnType(col, DOUBLE); + break; } } @@ -4819,7 +4824,7 @@ protected float getMaxFloat() { for (int row = 0; row < getRowCount(); row++) { for (int col = 0; col < getColumnCount(); col++) { float value = getFloat(row, col); - if (!Float.isNaN(value)) { // TODO no, this should be comparing to the missing value + if (Float.compare(value, missingFloat) != 0) { //value now compares to missingFloat variable if (!found) { max = value; found = true; diff --git a/core/src/processing/opengl/PGraphicsOpenGL.java b/core/src/processing/opengl/PGraphicsOpenGL.java index 88164f43e1..b00fa43f92 100644 --- a/core/src/processing/opengl/PGraphicsOpenGL.java +++ b/core/src/processing/opengl/PGraphicsOpenGL.java @@ -5512,8 +5512,11 @@ protected void setImpl(PImage sourceImage, int targetX, int targetY) { updatePixelSize(); - // Copies the pixels + // make sure pixel arrays are available loadPixels(); + sourceImage.loadPixels(); + + // Copies the pixels int sourceOffset = sourceY * sourceImage.pixelWidth + sourceX; int targetOffset = targetY * pixelWidth + targetX; for (int y = sourceY; y < sourceY + sourceHeight; y++) { diff --git a/core/src/processing/webgpu/Material.java b/core/src/processing/webgpu/Material.java new file mode 100644 index 0000000000..74d3fe81c7 --- /dev/null +++ b/core/src/processing/webgpu/Material.java @@ -0,0 +1,39 @@ +package processing.webgpu; + +public class Material { + + private long id; + + private Material(long id) { + this.id = id; + } + + public static Material pbr() { + return new Material(PWebGPU.materialCreatePbr()); + } + + public static Material unlit() { + Material mat = pbr(); + mat.set("unlit", 1.0f); + return mat; + } + + public long id() { + return id; + } + + public void set(String name, float value) { + PWebGPU.materialSetFloat(id, name, value); + } + + public void set(String name, float r, float g, float b, float a) { + PWebGPU.materialSetFloat4(id, name, r, g, b, a); + } + + public void destroy() { + if (id != 0) { + PWebGPU.materialDestroy(id); + id = 0; + } + } +} diff --git a/core/src/processing/webgpu/PGraphicsWebGPU.java b/core/src/processing/webgpu/PGraphicsWebGPU.java new file mode 100644 index 0000000000..69975b4035 --- /dev/null +++ b/core/src/processing/webgpu/PGraphicsWebGPU.java @@ -0,0 +1,613 @@ +package processing.webgpu; + +import processing.core.PGraphics; +import processing.core.PImage; +import processing.core.PShape; +import processing.core.PSurface; + +import java.util.ArrayList; +import java.util.List; + +public class PGraphicsWebGPU extends PGraphics { + protected long surfaceId = 0; + private long graphicsId = 0; + + private long currentGeometry = 0; + private int shapeKind = 0; + private float normalX = 0, normalY = 0, normalZ = 1; + + private final List pendingDestroy = new ArrayList<>(); + + + @Override + public PSurface createSurface() { + return surface = new PSurfaceGLFW(this); + } + + protected void initWebGPUSurface(long windowHandle, long displayHandle, int width, int height, float scaleFactor) { + surfaceId = PWebGPU.createSurface(windowHandle, displayHandle, width, height, scaleFactor); + if (surfaceId == 0) { + System.err.println("Failed to create WebGPU surface"); + return; + } + graphicsId = PWebGPU.graphicsCreate(surfaceId, width, height); + if (graphicsId == 0) { + System.err.println("Failed to create WebGPU graphics context"); + } + } + + public long getSurfaceId() { + return surfaceId; + } + + @Override + public void setSize(int w, int h) { + super.setSize(w, h); + if (surfaceId != 0) { + PWebGPU.windowResized(surfaceId, pixelWidth, pixelHeight); + } + } + + @Override + public void beginDraw() { + super.beginDraw(); + if (graphicsId == 0) { + return; + } + PWebGPU.beginDraw(graphicsId); + checkSettings(); + } + + @Override + public void flush() { + super.flush(); + if (graphicsId == 0) { + return; + } + PWebGPU.flush(graphicsId); + + for (long geometryId : pendingDestroy) { + PWebGPU.geometryDestroy(geometryId); + } + pendingDestroy.clear(); + } + + @Override + public void endDraw() { + super.endDraw(); + if (graphicsId == 0) { + return; + } + PWebGPU.endDraw(graphicsId); + } + + @Override + public void dispose() { + super.dispose(); + if (surfaceId != 0) { + PWebGPU.destroySurface(surfaceId); + surfaceId = 0; + } + PWebGPU.exit(); + } + + // ── Background ────────────────────────────────────────────────────── + + @Override + protected void backgroundImpl() { + if (graphicsId == 0) { + return; + } + PWebGPU.backgroundColor(graphicsId, backgroundR, backgroundG, backgroundB, backgroundA); + } + + @Override + protected void backgroundImpl(PImage image) { + if (graphicsId == 0) { + return; + } + if (!(image instanceof PImageWebGPU)) { + throw new RuntimeException("WebGPU renderer requires PImageWebGPU. Use createImage()."); + } + PImageWebGPU img = (PImageWebGPU) image; + if (img.getId() == 0) { + img.loadPixels(); + byte[] rgba = pixelsToRGBA(img.pixels); + long imageId = PWebGPU.imageCreate(img.pixelWidth, img.pixelHeight, rgba); + img.setId(imageId); + } + PWebGPU.backgroundImage(graphicsId, img.getId()); + } + + // ── Fill / stroke ─────────────────────────────────────────────────── + + @Override + protected void fillFromCalc() { + super.fillFromCalc(); + if (graphicsId == 0) { + return; + } + if (fill) { + PWebGPU.setFill(graphicsId, fillR, fillG, fillB, fillA); + } else { + PWebGPU.noFill(graphicsId); + } + } + + @Override + protected void strokeFromCalc() { + super.strokeFromCalc(); + if (graphicsId == 0) { + return; + } + if (stroke) { + PWebGPU.setStrokeColor(graphicsId, strokeR, strokeG, strokeB, strokeA); + } else { + PWebGPU.noStroke(graphicsId); + } + } + + @Override + public void strokeWeight(float weight) { + super.strokeWeight(weight); + if (graphicsId == 0) { + return; + } + PWebGPU.setStrokeWeight(graphicsId, weight); + } + + @Override + public void noFill() { + super.noFill(); + if (graphicsId == 0) { + return; + } + PWebGPU.noFill(graphicsId); + } + + @Override + public void noStroke() { + super.noStroke(); + if (graphicsId == 0) { + return; + } + PWebGPU.noStroke(graphicsId); + } + + @Override + public void strokeCap(int cap) { + super.strokeCap(cap); + if (graphicsId == 0) { + return; + } + byte nativeCap = switch (cap) { + case ROUND -> PWebGPU.STROKE_CAP_ROUND; + case SQUARE -> PWebGPU.STROKE_CAP_SQUARE; + case PROJECT -> PWebGPU.STROKE_CAP_PROJECT; + default -> PWebGPU.STROKE_CAP_ROUND; + }; + PWebGPU.setStrokeCap(graphicsId, nativeCap); + } + + @Override + public void strokeJoin(int join) { + super.strokeJoin(join); + if (graphicsId == 0) { + return; + } + byte nativeJoin = switch (join) { + case ROUND -> PWebGPU.STROKE_JOIN_ROUND; + case MITER -> PWebGPU.STROKE_JOIN_MITER; + case BEVEL -> PWebGPU.STROKE_JOIN_BEVEL; + default -> PWebGPU.STROKE_JOIN_ROUND; + }; + PWebGPU.setStrokeJoin(graphicsId, nativeJoin); + } + + // ── Blend mode ────────────────────────────────────────────────────── + + @Override + public void blendMode(int mode) { + super.blendMode(mode); + if (graphicsId == 0) { + return; + } + byte nativeMode = switch (mode) { + case BLEND -> PWebGPU.BLEND_MODE_BLEND; + case ADD -> PWebGPU.BLEND_MODE_ADD; + case SUBTRACT -> PWebGPU.BLEND_MODE_SUBTRACT; + case DARKEST -> PWebGPU.BLEND_MODE_DARKEST; + case LIGHTEST -> PWebGPU.BLEND_MODE_LIGHTEST; + case DIFFERENCE -> PWebGPU.BLEND_MODE_DIFFERENCE; + case EXCLUSION -> PWebGPU.BLEND_MODE_EXCLUSION; + case MULTIPLY -> PWebGPU.BLEND_MODE_MULTIPLY; + case SCREEN -> PWebGPU.BLEND_MODE_SCREEN; + case REPLACE -> PWebGPU.BLEND_MODE_REPLACE; + default -> PWebGPU.BLEND_MODE_BLEND; + }; + PWebGPU.setBlendMode(graphicsId, nativeMode); + } + + // ── 2D primitives ─────────────────────────────────────────────────── + + @Override + protected void rectImpl(float x1, float y1, float x2, float y2) { + rectImpl(x1, y1, x2, y2, 0, 0, 0, 0); + } + + @Override + protected void rectImpl(float x1, float y1, float x2, float y2, + float tl, float tr, float br, float bl) { + if (graphicsId == 0) { + return; + } + PWebGPU.rect(graphicsId, x1, y1, x2 - x1, y2 - y1, tl, tr, br, bl); + } + + @Override + protected void ellipseImpl(float a, float b, float c, float d) { + if (graphicsId == 0) { + return; + } + // ellipseImpl receives corner-form; native expects center. + PWebGPU.ellipse(graphicsId, a + c / 2f, b + d / 2f, c, d); + } + + @Override + protected void arcImpl(float a, float b, float c, float d, + float start, float stop, int mode) { + if (graphicsId == 0) { + return; + } + PWebGPU.arc(graphicsId, a, b, c, d, start, stop, (byte) mode); + } + + @Override + public void line(float x1, float y1, float x2, float y2) { + if (graphicsId == 0) { + return; + } + PWebGPU.line(graphicsId, x1, y1, x2, y2); + } + + @Override + public void point(float x, float y) { + if (graphicsId == 0) { + return; + } + PWebGPU.point(graphicsId, x, y); + } + + @Override + public void triangle(float x1, float y1, float x2, float y2, float x3, float y3) { + if (graphicsId == 0) { + return; + } + PWebGPU.triangle(graphicsId, x1, y1, x2, y2, x3, y3); + } + + @Override + public void quad(float x1, float y1, float x2, float y2, + float x3, float y3, float x4, float y4) { + if (graphicsId == 0) { + return; + } + PWebGPU.quad(graphicsId, x1, y1, x2, y2, x3, y3, x4, y4); + } + + // ── Curves ────────────────────────────────────────────────────────── + + @Override + public void bezier(float x1, float y1, float x2, float y2, + float x3, float y3, float x4, float y4) { + if (graphicsId == 0) { + return; + } + PWebGPU.bezier(graphicsId, x1, y1, x2, y2, x3, y3, x4, y4); + } + + @Override + public void curve(float x1, float y1, float x2, float y2, + float x3, float y3, float x4, float y4) { + if (graphicsId == 0) { + return; + } + PWebGPU.curve(graphicsId, x1, y1, x2, y2, x3, y3, x4, y4); + } + + // ── 3D shapes ─────────────────────────────────────────────────────── + + @Override + public void box(float w, float h, float d) { + if (graphicsId == 0) { + return; + } + long boxGeometry = PWebGPU.geometryBox(w, h, d); + PWebGPU.model(graphicsId, boxGeometry); + pendingDestroy.add(boxGeometry); + } + + @Override + public void sphere(float r) { + if (graphicsId == 0) { + return; + } + long sphereGeometry = PWebGPU.geometrySphere(r, sphereDetailU, sphereDetailV); + PWebGPU.model(graphicsId, sphereGeometry); + pendingDestroy.add(sphereGeometry); + } + + // ── Vertex shapes ─────────────────────────────────────────────────── + + @Override + public void beginShape(int kind) { + super.beginShape(kind); + if (graphicsId == 0) { + return; + } + shapeKind = kind; + byte topology = shapeKindToTopology(kind); + currentGeometry = PWebGPU.geometryCreate(topology); + } + + private byte shapeKindToTopology(int kind) { + return switch (kind) { + case POINTS -> PWebGPU.TOPOLOGY_POINT_LIST; + case LINES -> PWebGPU.TOPOLOGY_LINE_LIST; + case LINE_STRIP -> PWebGPU.TOPOLOGY_LINE_STRIP; + case TRIANGLES -> PWebGPU.TOPOLOGY_TRIANGLE_LIST; + case TRIANGLE_STRIP -> PWebGPU.TOPOLOGY_TRIANGLE_STRIP; + case TRIANGLE_FAN, QUADS, QUAD_STRIP, POLYGON -> PWebGPU.TOPOLOGY_TRIANGLE_LIST; + default -> PWebGPU.TOPOLOGY_TRIANGLE_LIST; + }; + } + + @Override + public void normal(float nx, float ny, float nz) { + normalX = nx; + normalY = ny; + normalZ = nz; + } + + @Override + public void vertex(float x, float y) { + vertex(x, y, 0); + } + + @Override + public void vertex(float x, float y, float z) { + if (currentGeometry == 0) { + return; + } + PWebGPU.geometryColor(currentGeometry, fillR, fillG, fillB, fillA); + PWebGPU.geometryNormal(currentGeometry, normalX, normalY, normalZ); + PWebGPU.geometryVertex(currentGeometry, x, y, z); + } + + @Override + public void endShape(int mode) { + if (graphicsId == 0 || currentGeometry == 0) { + return; + } + + if (shapeKind == QUADS) { + int vertexCount = PWebGPU.geometryVertexCount(currentGeometry); + for (int i = 0; i < vertexCount; i += 4) { + PWebGPU.geometryIndex(currentGeometry, i); + PWebGPU.geometryIndex(currentGeometry, i + 1); + PWebGPU.geometryIndex(currentGeometry, i + 2); + PWebGPU.geometryIndex(currentGeometry, i); + PWebGPU.geometryIndex(currentGeometry, i + 2); + PWebGPU.geometryIndex(currentGeometry, i + 3); + } + } + + PWebGPU.model(graphicsId, currentGeometry); + pendingDestroy.add(currentGeometry); + currentGeometry = 0; + } + + // ── Transform matrix ──────────────────────────────────────────────── + + @Override + public void pushMatrix() { + if (graphicsId == 0) { + return; + } + PWebGPU.pushMatrix(graphicsId); + } + + @Override + public void popMatrix() { + if (graphicsId == 0) { + return; + } + PWebGPU.popMatrix(graphicsId); + } + + @Override + public void resetMatrix() { + if (graphicsId == 0) { + return; + } + PWebGPU.resetMatrix(graphicsId); + } + + @Override + public void translate(float x, float y) { + if (graphicsId == 0) { + return; + } + PWebGPU.translate(graphicsId, x, y); + } + + @Override + public void rotate(float angle) { + if (graphicsId == 0) { + return; + } + PWebGPU.rotate(graphicsId, angle); + } + + @Override + public void scale(float x, float y) { + if (graphicsId == 0) { + return; + } + PWebGPU.scale(graphicsId, x, y); + } + + @Override + public void shearX(float angle) { + if (graphicsId == 0) { + return; + } + PWebGPU.shearX(graphicsId, angle); + } + + @Override + public void shearY(float angle) { + if (graphicsId == 0) { + return; + } + PWebGPU.shearY(graphicsId, angle); + } + + // ── 3D camera / projection ────────────────────────────────────────── + + @Override + public void camera(float eyeX, float eyeY, float eyeZ, + float centerX, float centerY, float centerZ, + float upX, float upY, float upZ) { + if (graphicsId == 0) { + return; + } + PWebGPU.mode3d(graphicsId); + PWebGPU.transformSetPosition(graphicsId, eyeX, eyeY, eyeZ); + PWebGPU.transformLookAt(graphicsId, centerX, centerY, centerZ); + } + + public void cameraPosition(float x, float y, float z) { + if (graphicsId == 0) { + return; + } + PWebGPU.transformSetPosition(graphicsId, x, y, z); + } + + public void cameraLookAt(float x, float y, float z) { + if (graphicsId == 0) { + return; + } + PWebGPU.transformLookAt(graphicsId, x, y, z); + } + + public void mode3d() { + if (graphicsId == 0) { + return; + } + PWebGPU.mode3d(graphicsId); + } + + @Override + public void perspective(float fov, float aspect, float near, float far) { + if (graphicsId == 0) { + return; + } + PWebGPU.mode3d(graphicsId); + PWebGPU.perspective(graphicsId, fov, aspect, near, far); + } + + @Override + public void ortho(float left, float right, float bottom, float top, float near, float far) { + if (graphicsId == 0) { + return; + } + PWebGPU.ortho(graphicsId, left, right, bottom, top, near, far); + } + + // ── Lights ─────────────────────────────────────────────────────────── + + @Override + public void directionalLight(float r, float g, float b, + float nx, float ny, float nz) { + if (graphicsId == 0) return; + long light = PWebGPU.lightCreateDirectional(graphicsId, r, g, b, 1.0f, 600.0f); + PWebGPU.transformSetRotation(light, nx, ny, nz); + } + + @Override + public void pointLight(float r, float g, float b, + float x, float y, float z) { + if (graphicsId == 0) return; + long light = PWebGPU.lightCreatePoint(graphicsId, r, g, b, 1.0f, 100000.0f, 800.0f, 0.0f); + PWebGPU.transformSetPosition(light, x, y, z); + } + + public long directionalLight(float r, float g, float b, float illuminance) { + if (graphicsId == 0) return 0; + return PWebGPU.lightCreateDirectional(graphicsId, r, g, b, 1.0f, illuminance); + } + + public long pointLight(float r, float g, float b, + float intensity, float range, float radius, + float x, float y, float z) { + if (graphicsId == 0) return 0; + long light = PWebGPU.lightCreatePoint(graphicsId, r, g, b, 1.0f, intensity, range, radius); + PWebGPU.transformSetPosition(light, x, y, z); + return light; + } + + public long spotLight(float r, float g, float b, + float intensity, float range, float radius, + float innerAngle, float outerAngle) { + if (graphicsId == 0) return 0; + return PWebGPU.lightCreateSpot(graphicsId, r, g, b, 1.0f, + intensity, range, radius, innerAngle, outerAngle); + } + + // ── Images / shapes ───────────────────────────────────────────────── + + public PImageWebGPU createImage(int width, int height, int format) { + return new PImageWebGPU(width, height, format); + } + + @Override + public PShape createShape() { + return new PShapeWebGPU(this, PShape.GEOMETRY); + } + + @Override + public PShape createShape(int type) { + return new PShapeWebGPU(this, type); + } + + public void model(long geometryId) { + if (graphicsId == 0) { + return; + } + PWebGPU.model(graphicsId, geometryId); + } + + // ── Materials ─────────────────────────────────────────────────────── + + public void useMaterial(Material mat) { + if (graphicsId == 0) { + return; + } + PWebGPU.material(graphicsId, mat.id()); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private byte[] pixelsToRGBA(int[] pixels) { + byte[] rgba = new byte[pixels.length * 4]; + for (int i = 0; i < pixels.length; i++) { + int pixel = pixels[i]; + rgba[i * 4] = (byte) ((pixel >> 16) & 0xFF); + rgba[i * 4 + 1] = (byte) ((pixel >> 8) & 0xFF); + rgba[i * 4 + 2] = (byte) (pixel & 0xFF); + rgba[i * 4 + 3] = (byte) ((pixel >> 24) & 0xFF); + } + return rgba; + } +} diff --git a/core/src/processing/webgpu/PImageWebGPU.java b/core/src/processing/webgpu/PImageWebGPU.java new file mode 100644 index 0000000000..d60d6530aa --- /dev/null +++ b/core/src/processing/webgpu/PImageWebGPU.java @@ -0,0 +1,28 @@ +package processing.webgpu; + +import processing.core.PImage; + +public class PImageWebGPU extends PImage { + + protected long id = 0; + + public PImageWebGPU() { + super(); + } + + public PImageWebGPU(int width, int height) { + super(width, height); + } + + public PImageWebGPU(int width, int height, int format) { + super(width, height, format); + } + + public long getId() { + return id; + } + + public void setId(long imageId) { + this.id = imageId; + } +} diff --git a/core/src/processing/webgpu/PShapeWebGPU.java b/core/src/processing/webgpu/PShapeWebGPU.java new file mode 100644 index 0000000000..54793c7a28 --- /dev/null +++ b/core/src/processing/webgpu/PShapeWebGPU.java @@ -0,0 +1,290 @@ +package processing.webgpu; + +import processing.core.PGraphics; +import processing.core.PShape; +import processing.core.PVector; + +/** + * WebGPU implementation of PShape. + */ +public class PShapeWebGPU extends PShape { + + /** Reference to the graphics context */ + protected PGraphicsWebGPU pg; + + /** Native geometry ID (0 = not yet created) */ + protected long geometryId = 0; + + /** Native layout ID for custom vertex attributes */ + protected long layoutId = 0; + + /** Current topology for this shape */ + protected byte topology = PWebGPU.TOPOLOGY_TRIANGLE_LIST; + + /** Track if we're currently building the shape */ + protected boolean building = false; + + /** Pending normal for next vertex */ + protected float normalX, normalY, normalZ; + protected boolean hasNormal = false; + + /** Pending color for next vertex */ + protected float colorR, colorG, colorB, colorA; + protected boolean hasColor = false; + + /** Pending UV for next vertex */ + protected float uvU, uvV; + protected boolean hasUV = false; + + /** + * Create a new WebGPU shape. + */ + public PShapeWebGPU(PGraphicsWebGPU pg, int family) { + this.pg = pg; + this.family = family; + } + + /** + * Map Processing shape kinds to WebGPU topologies. + */ + protected byte kindToTopology(int kind) { + return switch (kind) { + case POINTS -> PWebGPU.TOPOLOGY_POINT_LIST; + case LINES -> PWebGPU.TOPOLOGY_LINE_LIST; + case LINE_STRIP -> PWebGPU.TOPOLOGY_LINE_STRIP; + case TRIANGLES -> PWebGPU.TOPOLOGY_TRIANGLE_LIST; + case TRIANGLE_STRIP -> PWebGPU.TOPOLOGY_TRIANGLE_STRIP; + case TRIANGLE_FAN -> PWebGPU.TOPOLOGY_TRIANGLE_LIST; // Will need tessellation + case QUADS -> PWebGPU.TOPOLOGY_TRIANGLE_LIST; // Will need tessellation + case QUAD_STRIP -> PWebGPU.TOPOLOGY_TRIANGLE_STRIP; + default -> PWebGPU.TOPOLOGY_TRIANGLE_LIST; + }; + } + + @Override + public void beginShape(int kind) { + this.kind = kind; + this.topology = kindToTopology(kind); + + // Create or reset the geometry + if (geometryId != 0) { + PWebGPU.geometryDestroy(geometryId); + } + geometryId = PWebGPU.geometryCreate(topology); + building = true; + + // Reset pending attributes + hasNormal = false; + hasColor = false; + hasUV = false; + } + + @Override + public void endShape(int mode) { + building = false; + // If CLOSE mode and we have a polygon, we might need to add indices + // For now, the geometry is ready to be rendered + } + + @Override + public void normal(float nx, float ny, float nz) { + if (geometryId != 0) { + PWebGPU.geometryNormal(geometryId, nx, ny, nz); + } + normalX = nx; + normalY = ny; + normalZ = nz; + hasNormal = true; + } + + @Override + public void vertex(float x, float y) { + vertex(x, y, 0); + } + + @Override + public void vertex(float x, float y, float z) { + if (geometryId == 0) { + return; + } + if (hasColor) { + PWebGPU.geometryColor(geometryId, colorR, colorG, colorB, colorA); + } + if (hasUV) { + PWebGPU.geometryUv(geometryId, uvU, uvV); + } + PWebGPU.geometryVertex(geometryId, x, y, z); + } + + @Override + public void vertex(float x, float y, float u, float v) { + texture(u, v); + vertex(x, y, 0); + } + + @Override + public void vertex(float x, float y, float z, float u, float v) { + texture(u, v); + vertex(x, y, z); + } + + /** + * Set texture coordinates for the next vertex. + */ + public void texture(float u, float v) { + uvU = u; + uvV = v; + hasUV = true; + } + + /** + * Set the fill color for subsequent vertices. + */ + @Override + public void fill(int rgb) { + colorR = ((rgb >> 16) & 0xFF) / 255f; + colorG = ((rgb >> 8) & 0xFF) / 255f; + colorB = (rgb & 0xFF) / 255f; + colorA = ((rgb >> 24) & 0xFF) / 255f; + hasColor = true; + } + + @Override + public void fill(float gray) { + colorR = colorG = colorB = gray / 255f; + colorA = 1f; + hasColor = true; + } + + @Override + public void fill(float r, float g, float b) { + colorR = r / 255f; + colorG = g / 255f; + colorB = b / 255f; + colorA = 1f; + hasColor = true; + } + + @Override + public void fill(float r, float g, float b, float a) { + colorR = r / 255f; + colorG = g / 255f; + colorB = b / 255f; + colorA = a / 255f; + hasColor = true; + } + + /** + * Add an index for indexed rendering. + */ + public void index(int i) { + if (geometryId != 0) { + PWebGPU.geometryIndex(geometryId, i); + } + } + + @Override + public int getVertexCount() { + if (geometryId == 0) { + return 0; + } + return PWebGPU.geometryVertexCount(geometryId); + } + + /** + * Get the index count for this shape. + */ + public int getIndexCount() { + if (geometryId == 0) { + return 0; + } + return PWebGPU.geometryIndexCount(geometryId); + } + + @Override + public void setVertex(int index, float x, float y, float z) { + if (geometryId != 0) { + PWebGPU.geometrySetVertex(geometryId, index, x, y, z); + } + } + + @Override + public void setNormal(int index, float nx, float ny, float nz) { + if (geometryId != 0) { + PWebGPU.geometrySetNormal(geometryId, index, nx, ny, nz); + } + } + + /** + * Set the color of a specific vertex. + */ + public void setColor(int index, float r, float g, float b, float a) { + if (geometryId != 0) { + PWebGPU.geometrySetColor(geometryId, index, r, g, b, a); + } + } + + /** + * Set the UV coordinates of a specific vertex. + */ + public void setUv(int index, float u, float v) { + if (geometryId != 0) { + PWebGPU.geometrySetUv(geometryId, index, u, v); + } + } + + /** + * Get the native geometry ID for direct rendering. + */ + public long getGeometryId() { + return geometryId; + } + + /** + * Draw this shape using the associated graphics context. + */ + public void draw() { + if (geometryId != 0 && pg != null) { + pg.model(geometryId); + } + } + + @Override + protected void drawImpl(PGraphics g) { + if (geometryId != 0 && g instanceof PGraphicsWebGPU webgpu) { + webgpu.model(geometryId); + } + } + + /** + * Release native resources. + */ + public void dispose() { + if (geometryId != 0) { + PWebGPU.geometryDestroy(geometryId); + geometryId = 0; + } + if (layoutId != 0) { + PWebGPU.geometryLayoutDestroy(layoutId); + layoutId = 0; + } + } + + /** + * Create a box geometry. + */ + public static PShapeWebGPU createBox(PGraphicsWebGPU pg, float width, float height, float depth) { + PShapeWebGPU shape = new PShapeWebGPU(pg, GEOMETRY); + shape.geometryId = PWebGPU.geometryBox(width, height, depth); + return shape; + } + + /** + * Create a sphere geometry. + */ + public static PShapeWebGPU createSphere(PGraphicsWebGPU pg, float radius, int sectors, int stacks) { + PShapeWebGPU shape = new PShapeWebGPU(pg, GEOMETRY); + shape.geometryId = PWebGPU.geometrySphere(radius, sectors, stacks); + return shape; + } +} diff --git a/core/src/processing/webgpu/PSurfaceGLFW.java b/core/src/processing/webgpu/PSurfaceGLFW.java new file mode 100644 index 0000000000..852229729e --- /dev/null +++ b/core/src/processing/webgpu/PSurfaceGLFW.java @@ -0,0 +1,635 @@ +package processing.webgpu; + +import org.lwjgl.glfw.*; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.system.Platform; + +import processing.core.PApplet; +import processing.core.PConstants; +import processing.core.PGraphics; +import processing.core.PImage; +import processing.core.PSurface; +import processing.event.Event; +import processing.event.KeyEvent; +import processing.event.MouseEvent; + +import java.io.File; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +public class PSurfaceGLFW implements PSurface { + + protected PApplet sketch; + protected PGraphics graphics; + + protected long window; + protected long display; + protected boolean running = false; + + protected boolean paused; + private final Lock pauseLock = new ReentrantLock(); + private final Condition pauseCondition = pauseLock.newCondition(); + + protected float frameRateTarget = 60; + protected long frameRatePeriod = 1000000000L / 60L; + + private static final AtomicInteger windowCount = new AtomicInteger(0); + private static AtomicBoolean glfwInitialized = new AtomicBoolean(false); + + private GLFWFramebufferSizeCallback framebufferSizeCallback; + private GLFWWindowPosCallback windowPosCallback; + private GLFWCursorPosCallback cursorPosCallback; + private GLFWMouseButtonCallback mouseButtonCallback; + private GLFWScrollCallback scrollCallback; + private GLFWKeyCallback keyCallback; + private GLFWCharCallback charCallback; + private GLFWCursorEnterCallback cursorEnterCallback; + private GLFWWindowFocusCallback windowFocusCallback; + + private double lastCursorX; + private double lastCursorY; + private int currentMouseButton; + private int currentModifiers; + + // Cursor callbacks fire from inside glfwPollEvents and must do no FFI + // or allocation, or a high-poll-rate mouse can refill events faster + // than glfwPollEvents drains. Buffer the raw (x, y) here and replay + // them in runDrawLoop after polling returns. + private double[] cursorXs = new double[256]; + private double[] cursorYs = new double[256]; + private int cursorCount; + + public PSurfaceGLFW(PGraphics graphics) { + this.graphics = graphics; + } + + @Override + public void initOffscreen(PApplet sketch) { + throw new IllegalStateException("PSurfaceGLFW does not support offscreen rendering"); + } + + @Override + public void initFrame(PApplet sketch) { + this.sketch = sketch; + + if (glfwInitialized.compareAndSet(false, true)) { + GLFWErrorCallback.createPrint(System.err).set(); + if (!GLFW.glfwInit()) { + glfwInitialized.set(false); + throw new IllegalStateException("Failed to initialize GLFW"); + } + System.out.println("PSurfaceGLFW: GLFW initialized successfully"); + } + + GLFW.glfwDefaultWindowHints(); + GLFW.glfwWindowHint(GLFW.GLFW_CLIENT_API, GLFW.GLFW_NO_API); + GLFW.glfwWindowHint(GLFW.GLFW_VISIBLE, GLFW.GLFW_FALSE); + GLFW.glfwWindowHint(GLFW.GLFW_RESIZABLE, GLFW.GLFW_FALSE); + + window = GLFW.glfwCreateWindow(sketch.sketchWidth(), sketch.sketchHeight(), "Processing", + MemoryUtil.NULL, MemoryUtil.NULL); + if (window == MemoryUtil.NULL) { + throw new RuntimeException("Failed to create GLFW window"); + } + + display = GLFW.glfwGetPrimaryMonitor(); + + windowCount.incrementAndGet(); + + initListeners(); + + if (graphics instanceof PGraphicsWebGPU webgpu) { + PWebGPU.init(); + + long windowHandle = getWindowHandle(); + long displayHandle = getDisplayHandle(); + int width = sketch.sketchWidth(); + int height = sketch.sketchHeight(); + float scaleFactor = sketch.sketchPixelDensity(); + + webgpu.initWebGPUSurface(windowHandle, displayHandle, width, height, scaleFactor); + } + } + + protected void initListeners() { + long surfaceId = getSurfaceId(); + + // glfwSet*Callback returns the *previous* callback, not the new one. + // The new wrapper must be held in a field or it will be GC'd while + // still registered with native GLFW and events will silently stop. + + framebufferSizeCallback = GLFWFramebufferSizeCallback.create((win, w, h) -> { + if (sketch != null) sketch.postWindowResized(w, h); + }); + GLFW.glfwSetFramebufferSizeCallback(window, framebufferSizeCallback); + + windowPosCallback = GLFWWindowPosCallback.create((win, xpos, ypos) -> { + if (sketch != null) sketch.postWindowMoved(xpos, ypos); + }); + GLFW.glfwSetWindowPosCallback(window, windowPosCallback); + + cursorPosCallback = GLFWCursorPosCallback.create((win, xpos, ypos) -> { + int n = cursorCount; + if (n >= cursorXs.length) { + int newLen = cursorXs.length * 2; + double[] nx = new double[newLen]; + double[] ny = new double[newLen]; + System.arraycopy(cursorXs, 0, nx, 0, n); + System.arraycopy(cursorYs, 0, ny, 0, n); + cursorXs = nx; + cursorYs = ny; + } + cursorXs[n] = xpos; + cursorYs[n] = ypos; + cursorCount = n + 1; + lastCursorX = xpos; + lastCursorY = ypos; + }); + GLFW.glfwSetCursorPosCallback(window, cursorPosCallback); + + mouseButtonCallback = GLFWMouseButtonCallback.create((win, button, action, mods) -> { + int peButton = switch (button) { + case GLFW.GLFW_MOUSE_BUTTON_LEFT -> PConstants.LEFT; + case GLFW.GLFW_MOUSE_BUTTON_MIDDLE -> PConstants.CENTER; + case GLFW.GLFW_MOUSE_BUTTON_RIGHT -> PConstants.RIGHT; + default -> 0; + }; + currentModifiers = glfwModsToProcessing(mods); + boolean pressed = (action == GLFW.GLFW_PRESS); + if (surfaceId != 0 && peButton != 0) { + byte btn = (byte) (peButton == PConstants.LEFT ? 0 + : peButton == PConstants.CENTER ? 1 : 2); + PWebGPU.inputMouseButton(surfaceId, btn, pressed); + } + if (peButton != 0) { + if (pressed) currentMouseButton = peButton; + if (sketch != null) { + int peAction = pressed ? MouseEvent.PRESS : MouseEvent.RELEASE; + sketch.postEvent(new MouseEvent(null, System.currentTimeMillis(), + peAction, currentModifiers, + (int) lastCursorX, (int) lastCursorY, peButton, 1)); + } + if (!pressed) currentMouseButton = 0; + } + }); + GLFW.glfwSetMouseButtonCallback(window, mouseButtonCallback); + + scrollCallback = GLFWScrollCallback.create((win, xoffset, yoffset) -> { + if (surfaceId != 0) { + PWebGPU.inputScroll(surfaceId, (float) xoffset, (float) yoffset); + } + if (sketch != null) { + // Flip: Processing wheel is negative-up, GLFW is positive-up. + sketch.postEvent(new MouseEvent(null, System.currentTimeMillis(), + MouseEvent.WHEEL, currentModifiers, + (int) lastCursorX, (int) lastCursorY, 0, + (int) -yoffset)); + } + }); + GLFW.glfwSetScrollCallback(window, scrollCallback); + + keyCallback = GLFWKeyCallback.create((win, key, scancode, action, mods) -> { + currentModifiers = glfwModsToProcessing(mods); + if (surfaceId != 0 && action != GLFW.GLFW_REPEAT) { + PWebGPU.inputKey(surfaceId, key, action == GLFW.GLFW_PRESS); + } + if (sketch != null && action != GLFW.GLFW_REPEAT) { + int peAction = (action == GLFW.GLFW_PRESS) ? KeyEvent.PRESS : KeyEvent.RELEASE; + sketch.postEvent(new KeyEvent(null, System.currentTimeMillis(), + peAction, currentModifiers, + glfwKeyToChar(key), key)); + } + }); + GLFW.glfwSetKeyCallback(window, keyCallback); + + charCallback = GLFWCharCallback.create((win, codepoint) -> { + if (surfaceId != 0) { + PWebGPU.inputChar(surfaceId, 0, codepoint); + } + if (sketch != null) { + sketch.postEvent(new KeyEvent(null, System.currentTimeMillis(), + KeyEvent.TYPE, currentModifiers, + (char) codepoint, 0)); + } + }); + GLFW.glfwSetCharCallback(window, charCallback); + + cursorEnterCallback = GLFWCursorEnterCallback.create((win, entered) -> { + if (surfaceId != 0) { + if (entered) PWebGPU.inputCursorEnter(surfaceId); + else PWebGPU.inputCursorLeave(surfaceId); + } + }); + GLFW.glfwSetCursorEnterCallback(window, cursorEnterCallback); + + windowFocusCallback = GLFWWindowFocusCallback.create((win, focused) -> { + if (surfaceId != 0) PWebGPU.inputFocus(surfaceId, focused); + }); + GLFW.glfwSetWindowFocusCallback(window, windowFocusCallback); + } + + private static int glfwModsToProcessing(int mods) { + int m = 0; + if ((mods & GLFW.GLFW_MOD_SHIFT) != 0) m |= Event.SHIFT; + if ((mods & GLFW.GLFW_MOD_CONTROL) != 0) m |= Event.CTRL; + if ((mods & GLFW.GLFW_MOD_ALT) != 0) m |= Event.ALT; + if ((mods & GLFW.GLFW_MOD_SUPER) != 0) m |= Event.META; + return m; + } + + private static char glfwKeyToChar(int glfwKey) { + if (glfwKey >= 32 && glfwKey < 127) { + return (char) glfwKey; + } + return switch (glfwKey) { + case GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> '\n'; + case GLFW.GLFW_KEY_TAB -> '\t'; + case GLFW.GLFW_KEY_BACKSPACE -> '\b'; + case GLFW.GLFW_KEY_ESCAPE -> 27; + case GLFW.GLFW_KEY_DELETE -> 127; + default -> PConstants.CODED; + }; + } + + private long getSurfaceId() { + if (graphics instanceof PGraphicsWebGPU webgpu) { + return webgpu.getSurfaceId(); + } + return 0; + } + + @Override + public Object getNative() { + return window; + } + + public long getWindowHandle() { + if (Platform.get() == Platform.MACOSX) { + return GLFWNativeCocoa.glfwGetCocoaWindow(window); + } else if (Platform.get() == Platform.WINDOWS) { + return GLFWNativeWin32.glfwGetWin32Window(window); + } else if (Platform.get() == Platform.LINUX) { + // TODO: need to check if x11 or wayland + return GLFWNativeWayland.glfwGetWaylandWindow(window); + } else { + throw new UnsupportedOperationException("Window handle retrieval not implemented for this platform"); + } + } + + public long getDisplayHandle() { + if (Platform.get() == Platform.MACOSX) { + return 0; + } else if (Platform.get() == Platform.WINDOWS) { + return 0; + } else if (Platform.get() == Platform.LINUX) { + // TODO: need to check if x11 or wayland + return GLFWNativeWayland.glfwGetWaylandDisplay(); + } else { + throw new UnsupportedOperationException("Window handle retrieval not implemented for this platform"); + } + } + + @Override + public void setTitle(String title) { + if (window != MemoryUtil.NULL) { + GLFW.glfwSetWindowTitle(window, title); + } + } + + @Override + public void setVisible(boolean visible) { + if (window != MemoryUtil.NULL) { + if (visible) { + GLFW.glfwShowWindow(window); + } else { + GLFW.glfwHideWindow(window); + } + } + } + + @Override + public void setResizable(boolean resizable) { + if (window != MemoryUtil.NULL) { + GLFW.glfwSetWindowAttrib(window, GLFW.GLFW_RESIZABLE, + resizable ? GLFW.GLFW_TRUE : GLFW.GLFW_FALSE); + } + } + + @Override + public void setAlwaysOnTop(boolean always) { + if (window != MemoryUtil.NULL) { + GLFW.glfwSetWindowAttrib(window, GLFW.GLFW_FLOATING, + always ? GLFW.GLFW_TRUE : GLFW.GLFW_FALSE); + } + } + + @Override + public void setIcon(PImage icon) { + // TODO: set icon with glfw + } + + @Override + public void placeWindow(int[] location, int[] editorLocation) { + if (window == MemoryUtil.NULL) return; + + int x, y; + if (location != null) { + x = location[0]; + y = location[1]; + } else if (editorLocation != null) { + x = editorLocation[0] - 20; + y = editorLocation[1]; + + if (x - sketch.sketchWidth() < 10) { + long monitor = GLFW.glfwGetPrimaryMonitor(); + var vidmode = GLFW.glfwGetVideoMode(monitor); + if (vidmode != null) { + x = (vidmode.width() - sketch.sketchWidth()) / 2; + y = (vidmode.height() - sketch.sketchHeight()) / 2; + } else { + x = 100; + y = 100; + } + } + } else { + long monitor = GLFW.glfwGetPrimaryMonitor(); + var vidmode = GLFW.glfwGetVideoMode(monitor); + if (vidmode != null) { + x = (vidmode.width() - sketch.sketchWidth()) / 2; + y = (vidmode.height() - sketch.sketchHeight()) / 2; + } else { + x = 100; + y = 100; + } + } + + GLFW.glfwSetWindowPos(window, x, y); + } + + @Override + public void placePresent(int stopColor) { + // TODO: implement present mode support + } + + @Override + public void setLocation(int x, int y) { + if (window != MemoryUtil.NULL) { + GLFW.glfwSetWindowPos(window, x, y); + } + } + + @Override + public void setSize(int width, int height) { + if (width == sketch.width && height == sketch.height) { + return; + } + + sketch.width = width; + sketch.height = height; + graphics.setSize(width, height); + + if (window != MemoryUtil.NULL) { + GLFW.glfwSetWindowSize(window, width, height); + } + } + + @Override + public void setFrameRate(float fps) { + frameRateTarget = fps; + frameRatePeriod = (long) (1000000000.0 / frameRateTarget); + } + + @Override + public void setCursor(int kind) { + // TODO: implement cursor types + } + + @Override + public void setCursor(PImage image, int hotspotX, int hotspotY) { + // TODO: implement custom cursor + } + + @Override + public void showCursor() { + if (window != MemoryUtil.NULL) { + GLFW.glfwSetInputMode(window, GLFW.GLFW_CURSOR, GLFW.GLFW_CURSOR_NORMAL); + } + } + + @Override + public void hideCursor() { + if (window != MemoryUtil.NULL) { + GLFW.glfwSetInputMode(window, GLFW.GLFW_CURSOR, GLFW.GLFW_CURSOR_HIDDEN); + } + } + + @Override + public PImage loadImage(String path, Object... args) { + // TODO: implement image loading without awt + throw new UnsupportedOperationException("Image loading not yet implemented for WebGPU"); + } + + @Override + public boolean openLink(String url) { + // TODO: implement links without awt + return false; + } + + @Override + public void selectInput(String prompt, String callback, File file, Object callbackObject) { + throw new UnsupportedOperationException("File dialogs not yet implemented for WebGPU"); + } + + @Override + public void selectOutput(String prompt, String callback, File file, Object callbackObject) { + throw new UnsupportedOperationException("File dialogs not yet implemented for WebGPU"); + } + + @Override + public void selectFolder(String prompt, String callback, File file, Object callbackObject) { + throw new UnsupportedOperationException("Folder selection not yet implemented for WebGPU"); + } + + @Override + public void startThread() { + if (running) { + throw new IllegalStateException("Draw loop already running"); + } + + running = true; + runDrawLoop(); + } + + protected void runDrawLoop() { + GLFW.glfwShowWindow(window); + // macOS: when the JVM is launched as a child of another GUI process, + // NSApplication isn't activated and input events stop after a brief + // initial spurt unless we force focus. + GLFW.glfwFocusWindow(window); + + long beforeTime = System.nanoTime(); + long overSleepTime = 0L; + + sketch.start(); + + while (running) { + checkPause(); + + GLFW.glfwPollEvents(); + + if (GLFW.glfwWindowShouldClose(window)) { + sketch.exit(); + break; + } + + int n = cursorCount; + if (n > 0) { + long sid = getSurfaceId(); + long now = System.currentTimeMillis(); + for (int i = 0; i < n; i++) { + float x = (float) cursorXs[i]; + float y = (float) cursorYs[i]; + if (sid != 0) { + PWebGPU.inputMouseMove(sid, x, y); + } + if (sketch != null) { + int action = (currentMouseButton == 0) ? MouseEvent.MOVE : MouseEvent.DRAG; + sketch.postEvent(new MouseEvent(null, now, action, currentModifiers, + (int) x, (int) y, currentMouseButton, 0)); + } + } + cursorCount = 0; + } + + PWebGPU.inputFlush(); + + if (!sketch.finished) { + sketch.handleDraw(); + } + + long afterTime = System.nanoTime(); + long timeDiff = afterTime - beforeTime; + long sleepTime = (frameRatePeriod - timeDiff) - overSleepTime; + + if (sleepTime > 0) { + try { + Thread.sleep(sleepTime / 1000000L, (int) (sleepTime % 1000000L)); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + overSleepTime = (System.nanoTime() - afterTime) - sleepTime; + } else { + overSleepTime = 0L; + } + + beforeTime = System.nanoTime(); + } + + sketch.dispose(); + } + + @Override + public void pauseThread() { + paused = true; + } + + protected void checkPause() { + if (paused) { + pauseLock.lock(); + try { + while (paused) { + pauseCondition.await(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + pauseLock.unlock(); + } + } + } + + @Override + public void resumeThread() { + pauseLock.lock(); + try { + paused = false; + pauseCondition.signalAll(); + } finally { + pauseLock.unlock(); + } + } + + @Override + public boolean stopThread() { + if (!running) { + return false; + } + + running = false; + + try { + if (window != MemoryUtil.NULL) { + GLFW.glfwDestroyWindow(window); + window = MemoryUtil.NULL; + + if (windowCount.decrementAndGet() == 0) { + if (glfwInitialized.compareAndSet(true, false)) { + GLFW.glfwTerminate(); + System.out.println("PSurfaceGLFW: GLFW terminated"); + } + } + } + } finally { + freeCallbacks(); + } + + return true; + } + + private void freeCallbacks() { + if (framebufferSizeCallback != null) { + framebufferSizeCallback.free(); + framebufferSizeCallback = null; + } + if (windowPosCallback != null) { + windowPosCallback.free(); + windowPosCallback = null; + } + if (cursorPosCallback != null) { + cursorPosCallback.free(); + cursorPosCallback = null; + } + if (mouseButtonCallback != null) { + mouseButtonCallback.free(); + mouseButtonCallback = null; + } + if (scrollCallback != null) { + scrollCallback.free(); + scrollCallback = null; + } + if (keyCallback != null) { + keyCallback.free(); + keyCallback = null; + } + if (charCallback != null) { + charCallback.free(); + charCallback = null; + } + if (cursorEnterCallback != null) { + cursorEnterCallback.free(); + cursorEnterCallback = null; + } + if (windowFocusCallback != null) { + windowFocusCallback.free(); + windowFocusCallback = null; + } + } + + @Override + public boolean isStopped() { + return !running; + } +} diff --git a/core/src/processing/webgpu/PWebGPU.java b/core/src/processing/webgpu/PWebGPU.java new file mode 100644 index 0000000000..fd3b5c751c --- /dev/null +++ b/core/src/processing/webgpu/PWebGPU.java @@ -0,0 +1,878 @@ +package processing.webgpu; + +import processing.core.NativeLibrary; + +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; + +import static java.lang.foreign.MemorySegment.NULL; +import static processing.ffi.processing_h.*; +import processing.ffi.Color; + +public class PWebGPU { + + static { + ensureLoaded(); + } + + public static void ensureLoaded() { + NativeLibrary.ensureLoaded(); + } + + // ── Init / lifecycle ──────────────────────────────────────────────── + + public static void init() { + processing_init(); + checkError(); + } + + public static void exit() { + processing_exit((byte) 0); + checkError(); + } + + // ── Surface ───────────────────────────────────────────────────────── + + public static long createSurface(long windowHandle, long displayHandle, int width, int height, float scaleFactor) { + long surfaceId = processing_surface_create(windowHandle, displayHandle, width, height, scaleFactor); + checkError(); + return surfaceId; + } + + public static void destroySurface(long surfaceId) { + processing_surface_destroy(surfaceId); + checkError(); + } + + public static void windowResized(long surfaceId, int width, int height) { + processing_surface_resize(surfaceId, width, height); + checkError(); + } + + // ── Graphics context ──────────────────────────────────────────────── + + public static long graphicsCreate(long surfaceId, int width, int height) { + long graphicsId = processing_graphics_create(surfaceId, width, height); + checkError(); + return graphicsId; + } + + public static void graphicsDestroy(long graphicsId) { + processing_graphics_destroy(graphicsId); + checkError(); + } + + public static void beginDraw(long graphicsId) { + processing_begin_draw(graphicsId); + checkError(); + } + + public static void flush(long graphicsId) { + processing_flush(graphicsId); + checkError(); + } + + public static void endDraw(long graphicsId) { + processing_end_draw(graphicsId); + checkError(); + } + + // ── Background ────────────────────────────────────────────────────── + + public static void backgroundColor(long graphicsId, float r, float g, float b, float a) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment color = allocateColor(arena, r, g, b, a); + processing_background_color(graphicsId, color); + checkError(); + } + } + + public static void backgroundImage(long graphicsId, long imageId) { + processing_background_image(graphicsId, imageId); + checkError(); + } + + // ── Color mode ────────────────────────────────────────────────────── + + public static final byte COLOR_SPACE_SRGB = 0; + public static final byte COLOR_SPACE_HSB = 1; + public static final byte COLOR_SPACE_LINEAR = 2; + + public static void colorMode(long graphicsId, byte space, float max1, float max2, float max3, float maxAlpha) { + processing_color_mode(graphicsId, space, max1, max2, max3, maxAlpha); + checkError(); + } + + // ── Fill / stroke ─────────────────────────────────────────────────── + + public static void setFill(long graphicsId, float r, float g, float b, float a) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment color = allocateColor(arena, r, g, b, a); + processing_set_fill(graphicsId, color); + checkError(); + } + } + + public static void setStrokeColor(long graphicsId, float r, float g, float b, float a) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment color = allocateColor(arena, r, g, b, a); + processing_set_stroke_color(graphicsId, color); + checkError(); + } + } + + public static void setStrokeWeight(long graphicsId, float weight) { + processing_set_stroke_weight(graphicsId, weight); + checkError(); + } + + public static void noFill(long graphicsId) { + processing_no_fill(graphicsId); + checkError(); + } + + public static void noStroke(long graphicsId) { + processing_no_stroke(graphicsId); + checkError(); + } + + // ── Stroke style ──────────────────────────────────────────────────── + + public static final byte STROKE_CAP_ROUND = 0; + public static final byte STROKE_CAP_SQUARE = 1; + public static final byte STROKE_CAP_PROJECT = 2; + + public static final byte STROKE_JOIN_ROUND = 0; + public static final byte STROKE_JOIN_MITER = 1; + public static final byte STROKE_JOIN_BEVEL = 2; + + public static void setStrokeCap(long graphicsId, byte cap) { + processing_set_stroke_cap(graphicsId, cap); + checkError(); + } + + public static void setStrokeJoin(long graphicsId, byte join) { + processing_set_stroke_join(graphicsId, join); + checkError(); + } + + // ── Shape modes ───────────────────────────────────────────────────── + + public static void rectMode(long graphicsId, byte mode) { + processing_rect_mode(graphicsId, mode); + checkError(); + } + + public static void ellipseMode(long graphicsId, byte mode) { + processing_ellipse_mode(graphicsId, mode); + checkError(); + } + + // ── Blend modes ───────────────────────────────────────────────────── + + public static final byte BLEND_MODE_BLEND = 0; + public static final byte BLEND_MODE_ADD = 1; + public static final byte BLEND_MODE_SUBTRACT = 2; + public static final byte BLEND_MODE_DARKEST = 3; + public static final byte BLEND_MODE_LIGHTEST = 4; + public static final byte BLEND_MODE_DIFFERENCE = 5; + public static final byte BLEND_MODE_EXCLUSION = 6; + public static final byte BLEND_MODE_MULTIPLY = 7; + public static final byte BLEND_MODE_SCREEN = 8; + public static final byte BLEND_MODE_REPLACE = 9; + + public static void setBlendMode(long graphicsId, byte mode) { + processing_set_blend_mode(graphicsId, mode); + checkError(); + } + + // ── 2D drawing matrix ─────────────────────────────────────────────── + + public static void pushMatrix(long graphicsId) { + processing_push_matrix(graphicsId); + checkError(); + } + + public static void popMatrix(long graphicsId) { + processing_pop_matrix(graphicsId); + checkError(); + } + + public static void resetMatrix(long graphicsId) { + processing_reset_matrix(graphicsId); + checkError(); + } + + public static void translate(long graphicsId, float x, float y) { + processing_translate(graphicsId, x, y); + checkError(); + } + + public static void rotate(long graphicsId, float angle) { + processing_rotate(graphicsId, angle); + checkError(); + } + + public static void scale(long graphicsId, float x, float y) { + processing_scale(graphicsId, x, y); + checkError(); + } + + public static void shearX(long graphicsId, float angle) { + processing_shear_x(graphicsId, angle); + checkError(); + } + + public static void shearY(long graphicsId, float angle) { + processing_shear_y(graphicsId, angle); + checkError(); + } + + // ── 2D primitives ─────────────────────────────────────────────────── + + public static void rect(long graphicsId, float x, float y, float w, float h, + float tl, float tr, float br, float bl) { + processing_rect(graphicsId, x, y, w, h, tl, tr, br, bl); + checkError(); + } + + public static void ellipse(long graphicsId, float cx, float cy, float w, float h) { + processing_ellipse(graphicsId, cx, cy, w, h); + checkError(); + } + + public static void circle(long graphicsId, float cx, float cy, float d) { + processing_circle(graphicsId, cx, cy, d); + checkError(); + } + + public static void line(long graphicsId, float x1, float y1, float x2, float y2) { + processing_line(graphicsId, x1, y1, x2, y2); + checkError(); + } + + public static void triangle(long graphicsId, float x1, float y1, float x2, float y2, + float x3, float y3) { + processing_triangle(graphicsId, x1, y1, x2, y2, x3, y3); + checkError(); + } + + public static void quad(long graphicsId, float x1, float y1, float x2, float y2, + float x3, float y3, float x4, float y4) { + processing_quad(graphicsId, x1, y1, x2, y2, x3, y3, x4, y4); + checkError(); + } + + public static void point(long graphicsId, float x, float y) { + processing_point(graphicsId, x, y); + checkError(); + } + + public static void square(long graphicsId, float x, float y, float s) { + processing_square(graphicsId, x, y, s); + checkError(); + } + + public static void arc(long graphicsId, float cx, float cy, float w, float h, + float start, float stop, byte mode) { + processing_arc(graphicsId, cx, cy, w, h, start, stop, mode); + checkError(); + } + + public static void bezier(long graphicsId, float x1, float y1, float x2, float y2, + float x3, float y3, float x4, float y4) { + processing_bezier(graphicsId, x1, y1, x2, y2, x3, y3, x4, y4); + checkError(); + } + + public static void curve(long graphicsId, float x1, float y1, float x2, float y2, + float x3, float y3, float x4, float y4) { + processing_curve(graphicsId, x1, y1, x2, y2, x3, y3, x4, y4); + checkError(); + } + + // ── 3D primitives ─────────────────────────────────────────────────── + + public static void cylinder(long graphicsId, float radius, float height, int detail) { + processing_cylinder(graphicsId, radius, height, detail); + checkError(); + } + + public static void cone(long graphicsId, float radius, float height, int detail) { + processing_cone(graphicsId, radius, height, detail); + checkError(); + } + + public static void torus(long graphicsId, float radius, float tubeRadius, int majorSegments, int minorSegments) { + processing_torus(graphicsId, radius, tubeRadius, majorSegments, minorSegments); + checkError(); + } + + public static void plane(long graphicsId, float width, float height) { + processing_plane(graphicsId, width, height); + checkError(); + } + + public static void capsule(long graphicsId, float radius, float length, int detail) { + processing_capsule(graphicsId, radius, length, detail); + checkError(); + } + + public static void conicalFrustum(long graphicsId, float radiusTop, float radiusBottom, float height, int detail) { + processing_conical_frustum(graphicsId, radiusTop, radiusBottom, height, detail); + checkError(); + } + + public static void tetrahedron(long graphicsId, float radius) { + processing_tetrahedron(graphicsId, radius); + checkError(); + } + + // ── Vertex shapes ─────────────────────────────────────────────────── + + public static void beginShape(long graphicsId, byte kind) { + processing_begin_shape(graphicsId, kind); + checkError(); + } + + public static void endShape(long graphicsId, boolean close) { + processing_end_shape(graphicsId, close); + checkError(); + } + + public static void shapeVertex(long graphicsId, float x, float y) { + processing_vertex(graphicsId, x, y); + checkError(); + } + + public static void bezierVertex(long graphicsId, float cx1, float cy1, float cx2, float cy2, float x, float y) { + processing_bezier_vertex(graphicsId, cx1, cy1, cx2, cy2, x, y); + checkError(); + } + + public static void quadraticVertex(long graphicsId, float cx, float cy, float x, float y) { + processing_quadratic_vertex(graphicsId, cx, cy, x, y); + checkError(); + } + + public static void curveVertex(long graphicsId, float x, float y) { + processing_curve_vertex(graphicsId, x, y); + checkError(); + } + + public static void beginContour(long graphicsId) { + processing_begin_contour(graphicsId); + checkError(); + } + + public static void endContour(long graphicsId) { + processing_end_contour(graphicsId); + checkError(); + } + + // ── 3D mode / projection ──────────────────────────────────────────── + + public static void mode3d(long graphicsId) { + processing_mode_3d(graphicsId); + checkError(); + } + + public static void mode2d(long graphicsId) { + processing_mode_2d(graphicsId); + checkError(); + } + + public static void perspective(long graphicsId, float fov, float aspect, float near, float far) { + processing_perspective(graphicsId, fov, aspect, near, far); + checkError(); + } + + public static void ortho(long graphicsId, float left, float right, float bottom, float top, float near, float far) { + processing_ortho(graphicsId, left, right, bottom, top, near, far); + checkError(); + } + + // ── Entity transforms (3D objects: lights, geometry, etc.) ────────── + + public static void transformSetPosition(long entityId, float x, float y, float z) { + processing_transform_set_position(entityId, x, y, z); + checkError(); + } + + public static void transformTranslate(long entityId, float x, float y, float z) { + processing_transform_translate(entityId, x, y, z); + checkError(); + } + + public static void transformSetRotation(long entityId, float x, float y, float z) { + processing_transform_set_rotation(entityId, x, y, z); + checkError(); + } + + public static void transformRotateX(long entityId, float angle) { + processing_transform_rotate_x(entityId, angle); + checkError(); + } + + public static void transformRotateY(long entityId, float angle) { + processing_transform_rotate_y(entityId, angle); + checkError(); + } + + public static void transformRotateZ(long entityId, float angle) { + processing_transform_rotate_z(entityId, angle); + checkError(); + } + + public static void transformRotateAxis(long entityId, float angle, float axisX, float axisY, float axisZ) { + processing_transform_rotate_axis(entityId, angle, axisX, axisY, axisZ); + checkError(); + } + + public static void transformSetScale(long entityId, float x, float y, float z) { + processing_transform_set_scale(entityId, x, y, z); + checkError(); + } + + public static void transformScale(long entityId, float x, float y, float z) { + processing_transform_scale(entityId, x, y, z); + checkError(); + } + + public static void transformLookAt(long entityId, float targetX, float targetY, float targetZ) { + processing_transform_look_at(entityId, targetX, targetY, targetZ); + checkError(); + } + + public static void transformReset(long entityId) { + processing_transform_reset(entityId); + checkError(); + } + + // ── Lights ────────────────────────────────────────────────────────── + + public static long lightCreateDirectional(long graphicsId, float r, float g, float b, float a, float illuminance) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment color = allocateColor(arena, r, g, b, a); + long id = processing_light_create_directional(graphicsId, color, illuminance); + checkError(); + return id; + } + } + + public static long lightCreatePoint(long graphicsId, float r, float g, float b, float a, + float intensity, float range, float radius) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment color = allocateColor(arena, r, g, b, a); + long id = processing_light_create_point(graphicsId, color, intensity, range, radius); + checkError(); + return id; + } + } + + public static long lightCreateSpot(long graphicsId, float r, float g, float b, float a, + float intensity, float range, float radius, + float innerAngle, float outerAngle) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment color = allocateColor(arena, r, g, b, a); + long id = processing_light_create_spot(graphicsId, color, intensity, range, radius, innerAngle, outerAngle); + checkError(); + return id; + } + } + + // ── Materials ─────────────────────────────────────────────────────── + + public static long materialCreatePbr() { + long id = processing_material_create_pbr(); + checkError(); + return id; + } + + public static void materialSetFloat(long matId, String name, float value) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nameSegment = arena.allocateFrom(name); + processing_material_set_float(matId, nameSegment, value); + checkError(); + } + } + + public static void materialSetFloat4(long matId, String name, float r, float g, float b, float a) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nameSegment = arena.allocateFrom(name); + processing_material_set_float4(matId, nameSegment, r, g, b, a); + checkError(); + } + } + + public static void materialDestroy(long matId) { + processing_material_destroy(matId); + checkError(); + } + + public static void material(long graphicsId, long matId) { + processing_material(graphicsId, matId); + checkError(); + } + + // ── Images ────────────────────────────────────────────────────────── + + public static long imageCreate(int width, int height, byte[] data) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment dataSegment = arena.allocateFrom(java.lang.foreign.ValueLayout.JAVA_BYTE, data); + long imageId = processing_image_create(width, height, dataSegment, data.length); + checkError(); + return imageId; + } + } + + public static long imageLoad(String path) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment pathSegment = arena.allocateFrom(path); + long imageId = processing_image_load(pathSegment); + checkError(); + return imageId; + } + } + + public static void imageResize(long imageId, int newWidth, int newHeight) { + processing_image_resize(imageId, newWidth, newHeight); + checkError(); + } + + public static void imageReadback(long imageId, float[] buffer) { + try (Arena arena = Arena.ofConfined()) { + int numPixels = buffer.length / 4; + MemorySegment colorBuffer = Color.allocateArray(numPixels, arena); + processing_image_readback(imageId, colorBuffer, numPixels); + checkError(); + + for (int i = 0; i < numPixels; i++) { + MemorySegment color = Color.asSlice(colorBuffer, i); + buffer[i * 4] = Color.c1(color); + buffer[i * 4 + 1] = Color.c2(color); + buffer[i * 4 + 2] = Color.c3(color); + buffer[i * 4 + 3] = Color.a(color); + } + } + } + + // ── Input: event ingestion (called by PSurfaceGLFW) ───────────────── + + public static void inputMouseMove(long surfaceId, float x, float y) { + processing_input_mouse_move(surfaceId, x, y); + checkError(); + } + + public static void inputMouseButton(long surfaceId, byte button, boolean pressed) { + processing_input_mouse_button(surfaceId, button, pressed); + checkError(); + } + + public static void inputScroll(long surfaceId, float x, float y) { + processing_input_scroll(surfaceId, x, y); + checkError(); + } + + public static void inputKey(long surfaceId, int keyCode, boolean pressed) { + processing_input_key(surfaceId, keyCode, pressed); + checkError(); + } + + public static void inputChar(long surfaceId, int keyCode, int codepoint) { + processing_input_char(surfaceId, keyCode, codepoint); + checkError(); + } + + public static void inputCursorEnter(long surfaceId) { + processing_input_cursor_enter(surfaceId); + checkError(); + } + + public static void inputCursorLeave(long surfaceId) { + processing_input_cursor_leave(surfaceId); + checkError(); + } + + public static void inputFocus(long surfaceId, boolean focused) { + processing_input_focus(surfaceId, focused); + checkError(); + } + + public static void inputFlush() { + processing_input_flush(); + checkError(); + } + + // ── Input: state queries ──────────────────────────────────────────── + + public static float mouseX(long surfaceId) { + return processing_mouse_x(surfaceId); + } + + public static float mouseY(long surfaceId) { + return processing_mouse_y(surfaceId); + } + + public static float pmouseX(long surfaceId) { + return processing_pmouse_x(surfaceId); + } + + public static float pmouseY(long surfaceId) { + return processing_pmouse_y(surfaceId); + } + + public static boolean mouseIsPressed() { + return processing_mouse_is_pressed(); + } + + public static byte mouseButton() { + return processing_mouse_button(); + } + + public static boolean keyIsPressed() { + return processing_key_is_pressed(); + } + + public static boolean keyIsDown(int keyCode) { + return processing_key_is_down(keyCode); + } + + public static boolean keyJustPressed(int keyCode) { + return processing_key_just_pressed(keyCode); + } + + public static int key() { + return processing_key(); + } + + public static int keyCode() { + return processing_key_code(); + } + + public static float movedX() { + return processing_moved_x(); + } + + public static float movedY() { + return processing_moved_y(); + } + + public static float mouseWheel() { + return processing_mouse_wheel(); + } + + // ── Geometry ──────────────────────────────────────────────────────── + + public static final byte TOPOLOGY_POINT_LIST = 0; + public static final byte TOPOLOGY_LINE_LIST = 1; + public static final byte TOPOLOGY_LINE_STRIP = 2; + public static final byte TOPOLOGY_TRIANGLE_LIST = 3; + public static final byte TOPOLOGY_TRIANGLE_STRIP = 4; + + public static final byte ATTR_FORMAT_FLOAT = 1; + public static final byte ATTR_FORMAT_FLOAT2 = 2; + public static final byte ATTR_FORMAT_FLOAT3 = 3; + public static final byte ATTR_FORMAT_FLOAT4 = 4; + + public static long geometryLayoutCreate() { + long layoutId = processing_geometry_layout_create(); + checkError(); + return layoutId; + } + + public static void geometryLayoutAddPosition(long layoutId) { + processing_geometry_layout_add_position(layoutId); + checkError(); + } + + public static void geometryLayoutAddNormal(long layoutId) { + processing_geometry_layout_add_normal(layoutId); + checkError(); + } + + public static void geometryLayoutAddColor(long layoutId) { + processing_geometry_layout_add_color(layoutId); + checkError(); + } + + public static void geometryLayoutAddUv(long layoutId) { + processing_geometry_layout_add_uv(layoutId); + checkError(); + } + + public static void geometryLayoutAddAttribute(long layoutId, long attrId) { + processing_geometry_layout_add_attribute(layoutId, attrId); + checkError(); + } + + public static void geometryLayoutDestroy(long layoutId) { + processing_geometry_layout_destroy(layoutId); + checkError(); + } + + public static long geometryCreate(byte topology) { + long geoId = processing_geometry_create(topology); + checkError(); + return geoId; + } + + public static long geometryCreateWithLayout(long layoutId, byte topology) { + long geoId = processing_geometry_create_with_layout(layoutId, topology); + checkError(); + return geoId; + } + + public static long geometryBox(float width, float height, float depth) { + long geoId = processing_geometry_box(width, height, depth); + checkError(); + return geoId; + } + + public static long geometrySphere(float radius, int sectors, int stacks) { + long geoId = processing_geometry_sphere(radius, sectors, stacks); + checkError(); + return geoId; + } + + public static void geometryDestroy(long geoId) { + processing_geometry_destroy(geoId); + checkError(); + } + + public static void geometryNormal(long geoId, float nx, float ny, float nz) { + processing_geometry_normal(geoId, nx, ny, nz); + checkError(); + } + + public static void geometryColor(long geoId, float r, float g, float b, float a) { + processing_geometry_color(geoId, r, g, b, a); + checkError(); + } + + public static void geometryUv(long geoId, float u, float v) { + processing_geometry_uv(geoId, u, v); + checkError(); + } + + public static void geometryVertex(long geoId, float x, float y, float z) { + processing_geometry_vertex(geoId, x, y, z); + checkError(); + } + + public static void geometryIndex(long geoId, int i) { + processing_geometry_index(geoId, i); + checkError(); + } + + public static long geometryAttributeCreate(String name, byte format) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nameSegment = arena.allocateFrom(name); + long attrId = processing_geometry_attribute_create(nameSegment, format); + checkError(); + return attrId; + } + } + + public static void geometryAttributeDestroy(long attrId) { + processing_geometry_attribute_destroy(attrId); + checkError(); + } + + public static long geometryAttributePosition() { + return processing_geometry_attribute_position(); + } + + public static long geometryAttributeNormal() { + return processing_geometry_attribute_normal(); + } + + public static long geometryAttributeColor() { + return processing_geometry_attribute_color(); + } + + public static long geometryAttributeUv() { + return processing_geometry_attribute_uv(); + } + + public static void geometryAttributeFloat(long geoId, long attrId, float v) { + processing_geometry_attribute_float(geoId, attrId, v); + checkError(); + } + + public static void geometryAttributeFloat2(long geoId, long attrId, float x, float y) { + processing_geometry_attribute_float2(geoId, attrId, x, y); + checkError(); + } + + public static void geometryAttributeFloat3(long geoId, long attrId, float x, float y, float z) { + processing_geometry_attribute_float3(geoId, attrId, x, y, z); + checkError(); + } + + public static void geometryAttributeFloat4(long geoId, long attrId, float x, float y, float z, float w) { + processing_geometry_attribute_float4(geoId, attrId, x, y, z, w); + checkError(); + } + + public static int geometryVertexCount(long geoId) { + int count = processing_geometry_vertex_count(geoId); + checkError(); + return count; + } + + public static int geometryIndexCount(long geoId) { + int count = processing_geometry_index_count(geoId); + checkError(); + return count; + } + + public static void geometrySetVertex(long geoId, int index, float x, float y, float z) { + processing_geometry_set_vertex(geoId, index, x, y, z); + checkError(); + } + + public static void geometrySetNormal(long geoId, int index, float nx, float ny, float nz) { + processing_geometry_set_normal(geoId, index, nx, ny, nz); + checkError(); + } + + public static void geometrySetColor(long geoId, int index, float r, float g, float b, float a) { + processing_geometry_set_color(geoId, index, r, g, b, a); + checkError(); + } + + public static void geometrySetUv(long geoId, int index, float u, float v) { + processing_geometry_set_uv(geoId, index, u, v); + checkError(); + } + + public static void model(long graphicsId, long geoId) { + processing_model(graphicsId, geoId); + checkError(); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private static MemorySegment allocateColor(Arena arena, float r, float g, float b, float a) { + MemorySegment color = Color.allocate(arena); + Color.c1(color, r); + Color.c2(color, g); + Color.c3(color, b); + Color.a(color, a); + Color.space(color, COLOR_SPACE_SRGB); + return color; + } + + private static void checkError() { + MemorySegment ret = processing_check_error(); + if (ret.equals(NULL)) { + return; + } + + String errorMsg = ret.getString(0); + if (errorMsg != null && !errorMsg.isEmpty()) { + throw new PWebGPUException(errorMsg); + } + } +} diff --git a/core/src/processing/webgpu/PWebGPUException.java b/core/src/processing/webgpu/PWebGPUException.java new file mode 100644 index 0000000000..b3907e0653 --- /dev/null +++ b/core/src/processing/webgpu/PWebGPUException.java @@ -0,0 +1,13 @@ +package processing.webgpu; + +/** + * Unchecked exception thrown for WebGPU-related errors. + *

+ * WebGPU operations can fail for various reasons, such as unsupported hardware, but are not + * expected to be recoverable by the Processing application. + */ +public class PWebGPUException extends RuntimeException { + public PWebGPUException(String message) { + super(message); + } +} diff --git a/core/test/processing/core/PFontTest.java b/core/test/processing/core/PFontTest.java new file mode 100644 index 0000000000..386c624068 --- /dev/null +++ b/core/test/processing/core/PFontTest.java @@ -0,0 +1,37 @@ +package processing.core; + +import java.awt.Font; //Javas built in font class +import org.junit.Test; //Using Junit4 test implementation +import static org.junit.Assert.assertEquals; //To compare expected vs actual values + +public class PFontTest { + + @Test + public void testGetFontNameCorrectly() { + Font trueFont = new Font("Name", Font.PLAIN, 16); + PFont font = new PFont(trueFont, true, null); + assertEquals("Name", font.getName()); + } + + + @Test + public void testGetCorrectPSName() { + Font awtFont = new Font("Dialog", Font.PLAIN, 16); //Truth, what PFont size should be + PFont font = new PFont(awtFont, true, null); + assertEquals(awtFont.getPSName(), font.getPostScriptName()); //test, expecting + } + + @Test + public void testGetCorrectSize() { + Font awtFont = new Font("Dialog", Font.PLAIN, 16); //Truth, what PFont size should be + PFont font = new PFont(awtFont, true, null); + assertEquals(awtFont.getSize(), font.getSize()); //test, expecting + } + + @Test + public void testGetNative() { + Font awtFont = new Font("Dialog", Font.PLAIN, 16); + PFont font = new PFont(awtFont, true, null); + assertEquals(awtFont, font.getNative()); + } +} diff --git a/core/test/processing/core/PGraphicsTests.java b/core/test/processing/core/PGraphicsTests.java new file mode 100644 index 0000000000..8d30b8fb71 --- /dev/null +++ b/core/test/processing/core/PGraphicsTests.java @@ -0,0 +1,19 @@ +package processing.core; + +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import processing.core.PGraphics; + +public class PGraphicsTests { + + @Test + public void testCanvasSizeAfterSetSize() { + // Create a PGraphics object and set its size + PGraphics pg = new PGraphics(); + pg.setSize(200, 150); + + // Assert that both width and height are correctly initialized + assertEquals(200, pg.width); + assertEquals(150, pg.height); + } +} \ No newline at end of file diff --git a/core/test/processing/core/PShapeOBJTest.java b/core/test/processing/core/PShapeOBJTest.java new file mode 100644 index 0000000000..c69843a27f --- /dev/null +++ b/core/test/processing/core/PShapeOBJTest.java @@ -0,0 +1,236 @@ +package processing.core; + +import org.junit.Before; +import org.junit.Test; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.*; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileWriter; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +public class PShapeOBJTest { + private PApplet parent; + private ArrayList faces; + private ArrayList materials; + private ArrayList coords; + private ArrayList normals; + private ArrayList texcoords; + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Before + public void setUp() { + parent = new PApplet(); + faces = new ArrayList<>(); + materials = new ArrayList<>(); + coords = new ArrayList<>(); + normals = new ArrayList<>(); + texcoords = new ArrayList<>(); + } + + // Ensure a basic object is parsed correctly - if this fails, something is wrong + @Test + public void testBasicOBJParsing() { + + // Define the data to be parsed + String objData = + "v 0.0 0.0 0.0\n" + + "v 1.0 0.0 0.0\n" + + "v 0.0 1.0 0.0\n" + + "f 1 2 3\n"; + + // Create a buffered reader, and parse the data + BufferedReader reader = new BufferedReader(new StringReader(objData)); + PShapeOBJ.parseOBJ(parent, "", reader, faces, materials, coords, normals, texcoords); + + // Ensure the data was parsed correctly - can be expanded on as needed + assertEquals(3, coords.size()); + assertEquals(1, faces.size()); + assertEquals(3, faces.get(0).vertIdx.size()); + } + + // Ensure a basic material is parsed correctly + @Test + public void testMaterialParsing() throws Exception { + + // Define the data to be parsed + String mtlData = + "newmtl Material1\n" + + "Ka 0.2 0.2 0.2\n" + + "Kd 0.8 0.8 0.8\n" + + "Ks 1.0 1.0 1.0\n" + + "Ns 50.0\n" + + "d 1.0\n"; + + // Create a temporary file with this data + File mtlFile = tempFolder.newFile("test.mtl"); + try (FileWriter writer = new FileWriter(mtlFile)) { + writer.write(mtlData); + } + + // Create a buffered reader with the data, and initialize the materials hash + BufferedReader reader = new BufferedReader(new StringReader(mtlData)); + Map materialsHash = new HashMap<>(); + + // Parse the data + PShapeOBJ.parseMTL(parent, mtlFile.getAbsolutePath(), "", reader, materials, materialsHash); + + // Ensure the data was parsed correctly - can be expanded on as needed + assertEquals(1, materials.size()); + PShapeOBJ.OBJMaterial material = materials.get(0); + assertEquals("Material1", material.name); + assertEquals(0.2f, material.ka.x, 0.001f); + assertEquals(0.8f, material.kd.y, 0.001f); + assertEquals(1.0f, material.ks.z, 0.001f); + assertEquals(50.0f, material.ns, 0.001f); + assertEquals(1.0f, material.d, 0.001f); + } + + // Ensure verticies and normals are parsed correctly for a basic object + @Test + public void testVertexNormalParsing() { + + // Define the data to be parsed + String objData = + "v 0.0 0.0 0.0\n" + + "v 1.0 0.0 0.0\n" + + "v 0.0 1.0 0.0\n" + + "vn 0.0 0.0 1.0\n" + + "vn 0.0 1.0 0.0\n" + + "vn 1.0 0.0 0.0\n" + + "f 1//1 2//2 3//3\n"; + + // Create a buffered reader, and parse the data + BufferedReader reader = new BufferedReader(new StringReader(objData)); + PShapeOBJ.parseOBJ(parent, "", reader, faces, materials, coords, normals, texcoords); + + // Ensure the data was parsed correctly - can be expanded on as needed + assertEquals(3, normals.size()); + assertEquals(3, faces.get(0).normIdx.size()); + } + + // Ensure parsing properly handles texture coordinates as well + @Test + public void testTextureCoordinateParsing() { + + // Define the data to be parsed + String objData = + "v 0.0 0.0 0.0\n" + + "v 1.0 0.0 0.0\n" + + "v 0.0 1.0 0.0\n" + + "vt 0.0 0.0\n" + + "vt 1.0 0.0\n" + + "vt 0.0 1.0\n" + + "f 1/1 2/2 3/3\n"; + // Create a buffered reader with the data, and parse the data + BufferedReader reader = new BufferedReader(new StringReader(objData)); + PShapeOBJ.parseOBJ(parent, "", reader, faces, materials, coords, normals, texcoords); + + // Ensure the data was parsed correctly - can be expanded on as needed + assertEquals(3, texcoords.size()); + assertEquals(3, faces.get(0).texIdx.size()); + } + + // Ensure conversion from PVector to 32-bit color int works correctly + @Test + public void testRGBAValueConversion() { + + // Define the PVector color + PVector color = new PVector(1.0f, 0.5f, 0.0f); + + // Convert to 32-bit color int + int rgba = PShapeOBJ.rgbaValue(color); + + // Ensure the conversion was correct - note alpha is always 0xFF here + assertEquals(0xFF, (rgba >> 24) & 0xFF); // A + assertEquals(0xFF, (rgba >> 16) & 0xFF); // R + assertEquals(0x7F, (rgba >> 8) & 0xFF); // G + assertEquals(0x00, rgba & 0xFF); // B + } + + // Ensure conversion from PVector to 32-bit color int (with alpha) works correctly + @Test + public void testRGBAValueConversionWithAlpha() { + + // Define the PVector color, and alpha + PVector color = new PVector(1.0f, 0.5f, 0.0f); + float alpha = 0.5f; + + //Convert to 32-bit color int + int rgba = PShapeOBJ.rgbaValue(color, alpha); + + // Ensure the conversion was correct + assertEquals(0x7F, (rgba >> 24) & 0xFF); // A + assertEquals(0xFF, (rgba >> 16) & 0xFF); // R + assertEquals(0x7F, (rgba >> 8) & 0xFF); // G + assertEquals(0x00, rgba & 0xFF); // B + } + + // Ensure a newly-created OBJFace is initialized correctly + @Test + public void testOBJFaceCreation() { + + // Create the empty OBJFace + PShapeOBJ.OBJFace face = new PShapeOBJ.OBJFace(); + + // Verify attributes are initialized correctly - can be expanded on as needed + assertTrue(face.vertIdx.isEmpty()); + assertTrue(face.texIdx.isEmpty()); + assertTrue(face.normIdx.isEmpty()); + assertEquals(-1, face.matIdx); + assertEquals("", face.name); + } + + // Ensure a newly-created OBJFace is initialized correctly + @Test + public void testOBJMaterialCreation() { + + // Create the empty OBJMaterial + PShapeOBJ.OBJMaterial material = new PShapeOBJ.OBJMaterial("TestMaterial"); + + // Verify attributes are initialized correctly - can be expanded on as needed + assertEquals("TestMaterial", material.name); + assertEquals(0.5f, material.ka.x, 0.001f); + assertEquals(0.5f, material.kd.y, 0.001f); + assertEquals(0.5f, material.ks.z, 0.001f); + assertEquals(1.0f, material.d, 0.001f); + assertEquals(0.0f, material.ns, 0.001f); + assertNull(material.kdMap); + } + + // After previous tests, ensure a more complex shape, with verticies, materials, and normals is parsed correctly + @Test + public void testComplexFaceParsing() { + + // Define the data to be parsed + String objData = + "v 0.0 0.0 0.0\n" + + "v 1.0 0.0 0.0\n" + + "v 0.0 1.0 0.0\n" + + "v 1.0 1.0 0.0\n" + + "vt 0.0 0.0\n" + + "vt 1.0 0.0\n" + + "vt 0.0 1.0\n" + + "vt 1.0 1.0\n" + + "vn 0.0 0.0 1.0\n" + + "f 1/1/1 2/2/1 3/3/1 4/4/1\n"; + + // Create a buffered reader with the data, and parse the data + BufferedReader reader = new BufferedReader(new StringReader(objData)); + PShapeOBJ.parseOBJ(parent, "", reader, faces, materials, coords, normals, texcoords); + + // Ensure the data was parsed correctly - can be expanded on as needed + assertEquals(1, faces.size()); + PShapeOBJ.OBJFace face = faces.get(0); + assertEquals(4, face.vertIdx.size()); + assertEquals(4, face.texIdx.size()); + assertEquals(4, face.normIdx.size()); + } +} \ No newline at end of file diff --git a/core/test/processing/data/DoubleListTest.java b/core/test/processing/data/DoubleListTest.java new file mode 100644 index 0000000000..1b0c39de24 --- /dev/null +++ b/core/test/processing/data/DoubleListTest.java @@ -0,0 +1,87 @@ +package processing.data; + +import junit.framework.TestCase; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static java.lang.Double.NaN; +import static org.junit.Assert.*; + +public class DoubleListTest{ + @Test + public void testDefaultConstructor() { + // 10 is the default value in DoubleList + DoubleList testedList = new DoubleList(); + assertEquals(0, testedList.size()); + assertEquals(10, testedList.data.length); + } + + @Test + public void testConstructorWithLength() { + DoubleList testedList = new DoubleList(20); + assertEquals(0, testedList.size()); + assertEquals(20, testedList.data.length); + } + + @Test + public void testConstructorWithArray() { + double[] source = {1.0, 2.0}; + DoubleList testedList = new DoubleList(source); + assertEquals(2, testedList.size()); + assertEquals(2, testedList.data.length); + + assertEquals(1, testedList.get(0),0); + assertEquals(2, testedList.get(1),0); + } + + @Test + public void testConstructorWithEmptyArray() { + double[] source = {}; + DoubleList testedList = new DoubleList(source); + assertEquals(0, testedList.size()); + assertEquals(0, testedList.data.length); + } + + @Test + public void testConstructorWithIterableObject() { + List source = new ArrayList<>(Arrays.asList("1.1", "test", null, 4.5, -1)); + DoubleList testedList = new DoubleList(source); + assertEquals(5, testedList.size()); + + double[] expected = {1.1, NaN, NaN, 4.5, -1}; + + assertEquals(expected[0], testedList.get(0), 1e-7); + assertEquals(expected[1], testedList.get(1), 0); + assertTrue(Double.isNaN(testedList.get(1))); + assertEquals(expected[2], testedList.get(2), 0); + assertTrue(Double.isNaN(testedList.get(2))); + assertEquals(expected[3], testedList.get(3), 0); + assertEquals(expected[4], testedList.get(4), 0); + + assertArrayEquals(expected, testedList.values(), 1e-7); + } + + @Test + public void testConstructorWithObject() { + String eleStr = "Hello"; + double eleDouble = 10.0; + float eleFloat = 1.2f; + Object eleObj = new Object(); + + DoubleList testedList = new DoubleList(eleStr, eleDouble, eleFloat, eleObj); + + double[] expected = {NaN, 10.0, 1.2, NaN}; + + assertEquals(expected[0], testedList.get(0), 0); + assertTrue(Double.isNaN(testedList.get(0))); + assertEquals(expected[1], testedList.get(1), 0); + assertEquals(expected[2], testedList.get(2), 1e-7); + assertEquals(expected[3], testedList.get(3), 0); + assertTrue(Double.isNaN(testedList.get(3))); + + assertArrayEquals(expected, testedList.values(),1e-7); + } +} \ No newline at end of file diff --git a/core/test/processing/data/IntListTest.java b/core/test/processing/data/IntListTest.java index 3c422d84e4..c98f7a675f 100644 --- a/core/test/processing/data/IntListTest.java +++ b/core/test/processing/data/IntListTest.java @@ -151,6 +151,16 @@ public void testPopOnEmptyIntListThrowsException() { assertEquals("Can't call pop() on an empty list", exception.getMessage()); } + @Test + public void testGetOnNegativeIndexIntListThrowsException() { + IntList testedList = new IntList(); + ArrayIndexOutOfBoundsException exception = assertThrows(ArrayIndexOutOfBoundsException.class, () -> { + testedList.get(-1); + }); + + assertEquals("Array index out of range: -1", exception.getMessage()); + } + @Test public void testRemoveWithIndexGreaterThanSize() { IntList testedList = new IntList(); diff --git a/core/test/processing/data/SortTest.java b/core/test/processing/data/SortTest.java new file mode 100644 index 0000000000..7dab624f68 --- /dev/null +++ b/core/test/processing/data/SortTest.java @@ -0,0 +1,165 @@ +package processing.data; + +import org.junit.Test; +import static org.junit.Assert.*; + +/** + * Sort.java is an abstract class implementing quicksort with three abstract methods: + * 1. size() - returns the number of elements + * 2. compare(int a, int b) - compares elements at two indices + * 3. swap(int a, int b) - swaps elements at two indices + */ +public class SortTest { + + /** + * Concrete implementation of Sort for testing using an int array. + */ + private static class IntArraySort extends Sort { + int[] data; + + IntArraySort(int[] data) { + this.data = data; + } + + @Override + public int size() { + return data.length; + } + + @Override + public int compare(int a, int b) { + return Integer.compare(data[a], data[b]); + } + + @Override + public void swap(int a, int b) { + int temp = data[a]; + data[a] = data[b]; + data[b] = temp; + } + } + + @Test + public void testSortAlreadySorted() { + int[] data = {1, 2, 3, 4, 5}; + IntArraySort sorter = new IntArraySort(data); + sorter.run(); + assertArrayEquals(new int[]{1, 2, 3, 4, 5}, data); + } + + @Test + public void testSortReversed() { + int[] data = {5, 4, 3, 2, 1}; + IntArraySort sorter = new IntArraySort(data); + sorter.run(); + assertArrayEquals(new int[]{1, 2, 3, 4, 5}, data); + } + + @Test + public void testSortUnsorted() { + int[] data = {3, 1, 4, 1, 5, 9, 2, 6}; + IntArraySort sorter = new IntArraySort(data); + sorter.run(); + assertArrayEquals(new int[]{1, 1, 2, 3, 4, 5, 6, 9}, data); + } + + @Test + public void testSortSingleElement() { + int[] data = {42}; + IntArraySort sorter = new IntArraySort(data); + sorter.run(); + assertArrayEquals(new int[]{42}, data); + } + + @Test + public void testSortEmptyArray() { + int[] data = {}; + IntArraySort sorter = new IntArraySort(data); + sorter.run(); + assertArrayEquals(new int[]{}, data); + } + + @Test + public void testSortTwoElements() { + int[] data = {2, 1}; + IntArraySort sorter = new IntArraySort(data); + sorter.run(); + assertArrayEquals(new int[]{1, 2}, data); + } + + @Test + public void testSortTwoElementsAlreadySorted() { + int[] data = {1, 2}; + IntArraySort sorter = new IntArraySort(data); + sorter.run(); + assertArrayEquals(new int[]{1, 2}, data); + } + + @Test + public void testSortWithDuplicates() { + int[] data = {3, 3, 3, 3}; + IntArraySort sorter = new IntArraySort(data); + sorter.run(); + assertArrayEquals(new int[]{3, 3, 3, 3}, data); + } + + @Test + public void testSortWithNegativeNumbers() { + int[] data = {0, -3, 5, -1, 2}; + IntArraySort sorter = new IntArraySort(data); + sorter.run(); + assertArrayEquals(new int[]{-3, -1, 0, 2, 5}, data); + } + + @Test + public void testSortWithMixedDuplicatesAndNegatives() { + int[] data = {4, -2, 4, 0, -2}; + IntArraySort sorter = new IntArraySort(data); + sorter.run(); + assertArrayEquals(new int[]{-2, -2, 0, 4, 4}, data); + } + + @Test + public void testSizeReflectsArrayLength() { + int[] data = {10, 20, 30}; + IntArraySort sorter = new IntArraySort(data); + assertEquals(3, sorter.size()); + } + + @Test + public void testSwapExchangesElements() { + int[] data = {10, 20, 30}; + IntArraySort sorter = new IntArraySort(data); + sorter.swap(0, 2); + assertArrayEquals(new int[]{30, 20, 10}, data); + } + + @Test + public void testCompareReturnsNegativeWhenLess() { + int[] data = {1, 5}; + IntArraySort sorter = new IntArraySort(data); + assertTrue(sorter.compare(0, 1) < 0); + } + + @Test + public void testCompareReturnsPositiveWhenGreater() { + int[] data = {5, 1}; + IntArraySort sorter = new IntArraySort(data); + assertTrue(sorter.compare(0, 1) > 0); + } + + @Test + public void testCompareReturnsZeroWhenEqual() { + int[] data = {3, 3}; + IntArraySort sorter = new IntArraySort(data); + assertEquals(0, sorter.compare(0, 1)); + } + + @Test + public void testSortDescendingLargeArray() { + int[] data = {6, 5, 4, 3, 2, 1}; + IntArraySort sorter = new IntArraySort(data); + sorter.run(); + assertArrayEquals(new int[]{1, 2, 3, 4, 5, 6}, data); + } +} \ No newline at end of file diff --git a/core/test/processing/data/TableTest.java b/core/test/processing/data/TableTest.java index ddeb77bc11..ceb3920408 100644 --- a/core/test/processing/data/TableTest.java +++ b/core/test/processing/data/TableTest.java @@ -36,4 +36,74 @@ public void parseInto() { Assert.assertEquals(people[0].name, "Person1"); Assert.assertEquals(people[0].age, 30); } + + @Test + public void testGetMaxFloat() { + Table table = new Table(); + table.addColumn("col1", Table.FLOAT); + table.addColumn("col2", Table.FLOAT); + table.addColumn("col3", Table.FLOAT); + + //Normal case with positive values + TableRow row1 = table.addRow(); + row1.setFloat("col1", 5.5f); + row1.setFloat("col2", 10.2f); + row1.setFloat("col3", 3.7f); + + TableRow row2 = table.addRow(); + row2.setFloat("col1", 15.8f); + row2.setFloat("col2", 2.1f); + row2.setFloat("col3", 8.9f); + + assertEquals(15.8f, table.getMaxFloat(), 0.001f); + + //Table with negative values + Table table2 = new Table(); + table2.addColumn("col1", Table.FLOAT); + TableRow row3 = table2.addRow(); + row3.setFloat("col1", -5.5f); + TableRow row4 = table2.addRow(); + row4.setFloat("col1", -2.3f); + + assertEquals(-2.3f, table2.getMaxFloat(), 0.001f); + + //Table with missing values (NaN) + Table table3 = new Table(); + table3.addColumn("col1", Table.FLOAT); + table3.addColumn("col2", Table.FLOAT); + + TableRow row5 = table3.addRow(); + row5.setFloat("col1", Float.NaN); + row5.setFloat("col2", 7.5f); + + TableRow row6 = table3.addRow(); + row6.setFloat("col1", 12.3f); + row6.setFloat("col2", Float.NaN); + + assertEquals(12.3f, table3.getMaxFloat(), 0.001f); + + //Table with all missing values + Table table4 = new Table(); + table4.addColumn("col1", Table.FLOAT); + TableRow row7 = table4.addRow(); + row7.setFloat("col1", Float.NaN); + TableRow row8 = table4.addRow(); + row8.setFloat("col1", Float.NaN); + + assertTrue(Float.isNaN(table4.getMaxFloat())); + + //Empty table + Table table5 = new Table(); + table5.addColumn("col1", Table.FLOAT); + + assertTrue(Float.isNaN(table5.getMaxFloat())); + + //Single value + Table table6 = new Table(); + table6.addColumn("col1", Table.FLOAT); + TableRow row9 = table6.addRow(); + row9.setFloat("col1", 42.0f); + + assertEquals(42.0f, table6.getMaxFloat(), 0.001f); + } } diff --git a/core/test/processing/webgpu/PWebGPUTest.java b/core/test/processing/webgpu/PWebGPUTest.java new file mode 100644 index 0000000000..405e70e1c4 --- /dev/null +++ b/core/test/processing/webgpu/PWebGPUTest.java @@ -0,0 +1,13 @@ +package processing.webgpu; + +import org.junit.Test; + +/** + * Tests for the PWebGPU native interface. + */ +public class PWebGPUTest { + @Test + public void itLoads() { + PWebGPU.ensureLoaded(); + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000000..f2f02a91ed --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +group=org.processing +version=4.5.6 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 49d41db2ad..4455f92044 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -kotlin = "2.2.20" +kotlin = "2.3.21" compose-plugin = "1.9.1" jogl = "2.6.0" antlr = "4.13.2" diff --git a/gradle/plugins/library/build.gradle.kts b/gradle/plugins/library/build.gradle.kts index f70338b4ec..9a1cd9e4c7 100644 --- a/gradle/plugins/library/build.gradle.kts +++ b/gradle/plugins/library/build.gradle.kts @@ -23,6 +23,7 @@ repositories { dependencies { testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.test { @@ -30,4 +31,4 @@ tasks.test { } kotlin { jvmToolchain(17) -} \ No newline at end of file +} diff --git a/gradle/plugins/library/src/main/kotlin/ProcessingLibraryPlugin.kt b/gradle/plugins/library/src/main/kotlin/ProcessingLibraryPlugin.kt index 4514f581fd..9cac968141 100644 --- a/gradle/plugins/library/src/main/kotlin/ProcessingLibraryPlugin.kt +++ b/gradle/plugins/library/src/main/kotlin/ProcessingLibraryPlugin.kt @@ -36,8 +36,9 @@ class ProcessingLibraryPlugin : Plugin { target.dependencies.add("compileOnly", "org.processing:core:$processingVersion") } } + val javaVersionOverride = target.findProperty("enableWebGPU")?.toString()?.toBoolean()?.let { if (it) 25 else 17 } ?: 17 target.extensions.configure(JavaPluginExtension::class.java) { extension -> - extension.toolchain.languageVersion.set(JavaLanguageVersion.of(17)) + extension.toolchain.languageVersion.set(JavaLanguageVersion.of(javaVersionOverride)) } target.plugins.withType(JavaPlugin::class.java) { @@ -122,4 +123,4 @@ class ProcessingLibraryPlugin : Plugin { } } -} \ No newline at end of file +} diff --git a/gradle/plugins/settings.gradle.kts b/gradle/plugins/settings.gradle.kts index ab39f6aca7..dc4f98668e 100644 --- a/gradle/plugins/settings.gradle.kts +++ b/gradle/plugins/settings.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } include("library") \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d64cd49177..8bdaf60c75 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 94113f200e..2e1113280e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a4269..adff685a03 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -112,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -170,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -203,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 93e3f59f13..c4bdd3ab8e 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,22 +59,21 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/java/gradle/README.md b/java/gradle/README.md index b827972769..d49d609764 100644 --- a/java/gradle/README.md +++ b/java/gradle/README.md @@ -18,7 +18,9 @@ more advanced workflows. ## What is Gradle Gradle is a build tool commonly used in the Java ecosystem. It is responsible for tasks like compiling code, managing -dependencies, and running applications. You do not need to learn Gradle to use Processing in the P +dependencies, and running applications. You do not need to learn Gradle to use Processing in the PDE, but if you feel +ready to take your sketches beyond the PDE and into development environments such as IntelliJ or Eclipse, this plugin +helps bridge the gap between Processing and standard Java workflows. ## Usage @@ -110,4 +112,4 @@ plugins { `settings.gradle.kts` - create the file but leave blank -Then run all sketches at once with `gradle sketch` \ No newline at end of file +Then run all sketches at once with `gradle sketch` diff --git a/java/gradle/build.gradle.kts b/java/gradle/build.gradle.kts index 8680c0a851..d7562fe87a 100644 --- a/java/gradle/build.gradle.kts +++ b/java/gradle/build.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.api.internal.tasks.testing.junit.JUnitTestFramework + plugins{ `java-gradle-plugin` alias(libs.plugins.gradlePublish) diff --git a/java/gradle/example/settings.gradle.kts b/java/gradle/example/settings.gradle.kts index ee9c97e155..297c7c2f8d 100644 --- a/java/gradle/example/settings.gradle.kts +++ b/java/gradle/example/settings.gradle.kts @@ -2,4 +2,5 @@ rootProject.name = "processing-gradle-plugin-demo" pluginManagement { includeBuild("../../../") -} \ No newline at end of file +} +includeBuild("../../../") diff --git a/java/gradle/src/main/kotlin/DependenciesTask.kt b/java/gradle/src/main/kotlin/DependenciesTask.kt deleted file mode 100644 index 8e2cb9bca3..0000000000 --- a/java/gradle/src/main/kotlin/DependenciesTask.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.processing.java.gradle - -import org.gradle.api.DefaultTask -import org.gradle.api.GradleException -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.TaskAction -import java.io.File -import java.io.ObjectInputStream - -/* -* The DependenciesTask resolves the dependencies for the sketch based on the libraries used - */ -abstract class DependenciesTask: DefaultTask() { - @InputFile - val librariesMetaData: RegularFileProperty = project.objects.fileProperty() - - @InputFile - val sketchMetaData: RegularFileProperty = project.objects.fileProperty() - - init{ - librariesMetaData.convention(project.layout.buildDirectory.file("processing/libraries")) - sketchMetaData.convention(project.layout.buildDirectory.file("processing/sketch")) - } - - @TaskAction - fun execute() { - val sketchMetaFile = sketchMetaData.get().asFile - val librariesMetaFile = librariesMetaData.get().asFile - - val libraries = librariesMetaFile.inputStream().use { input -> - ObjectInputStream(input).readObject() as ArrayList - } - - val sketch = sketchMetaFile.inputStream().use { input -> - ObjectInputStream(input).readObject() as PDETask.SketchMeta - } - - val dependencies = mutableSetOf() - - // Loop over the import statements in the sketch and import the relevant jars from the libraries - sketch.importStatements.forEach import@{ statement -> - libraries.forEach { library -> - library.jars.forEach { jar -> - jar.classes.forEach { className -> - if (className.startsWith(statement)) { - dependencies.addAll(library.jars.map { it.path } ) - return@import - } - } - } - } - } - project.dependencies.add("implementation", project.files(dependencies) ) - - // TODO: Mutating the dependencies of configuration ':implementation' after it has been resolved or consumed. This - - // TODO: Add only if user is compiling for P2D or P3D - // Add JOGL and Gluegen dependencies - project.dependencies.add("runtimeOnly", "org.jogamp.jogl:jogl-all-main:2.5.0") - project.dependencies.add("runtimeOnly", "org.jogamp.gluegen:gluegen-rt:2.5.0") - - val os = System.getProperty("os.name").lowercase() - val arch = System.getProperty("os.arch").lowercase() - - val variant = when { - os.contains("mac") -> "macosx-universal" - os.contains("win") && arch.contains("64") -> "windows-amd64" - os.contains("linux") && arch.contains("aarch64") -> "linux-aarch64" - os.contains("linux") && arch.contains("arm") -> "linux-arm" - os.contains("linux") && arch.contains("amd64") -> "linux-amd64" - else -> throw GradleException("Unsupported OS/architecture: $os / $arch") - } - - project.dependencies.add("runtimeOnly", "org.jogamp.gluegen:gluegen-rt:2.5.0:natives-$variant") - project.dependencies.add("runtimeOnly", "org.jogamp.jogl:nativewindow:2.5.0:natives-$variant") - project.dependencies.add("runtimeOnly", "org.jogamp.jogl:newt:2.5.0:natives-$variant") - } -} \ No newline at end of file diff --git a/java/gradle/src/main/kotlin/LibrariesTask.kt b/java/gradle/src/main/kotlin/LibrariesTask.kt deleted file mode 100644 index 2ccca5cde7..0000000000 --- a/java/gradle/src/main/kotlin/LibrariesTask.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.processing.java.gradle - -import org.gradle.api.DefaultTask -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.TaskAction -import java.io.File -import java.io.ObjectOutputStream -import java.util.jar.JarFile - -/* -The libraries task scans the sketchbook libraries folder for all the libraries -This task stores the resulting information in a file that can be used later to resolve dependencies - */ -abstract class LibrariesTask : DefaultTask() { - - @InputFiles - val libraryDirectories: ConfigurableFileCollection = project.files() - - @OutputFile - val librariesMetaData: RegularFileProperty = project.objects.fileProperty() - - init{ - librariesMetaData.convention { project.gradle.gradleUserHomeDir.resolve("common/processing/libraries") } - } - - data class Jar( - val path: File, - val classes: List - ) : java.io.Serializable - - data class Library( - val jars: List - ) : java.io.Serializable - - @TaskAction - fun execute() { - val output = libraryDirectories.flatMap { librariesDirectory -> - if (!librariesDirectory.exists()) { - logger.error("Libraries directory (${librariesDirectory.path}) does not exist. Libraries will not be imported.") - return@flatMap emptyList() - } - val libraries = librariesDirectory - .listFiles { file -> file.isDirectory } - ?.map { folder -> - // Find all the jars in the sketchbook - val jars = folder.resolve("library") - .listFiles{ file -> file.extension == "jar" } - ?.map{ file -> - - // Inside each jar, look for the defined classes - val jar = JarFile(file) - val classes = jar.entries().asSequence() - .filter { entry -> entry.name.endsWith(".class") } - .map { entry -> entry.name } - .map { it.substringBeforeLast('/').replace('/', '.') } - .distinct() - .toList() - - // Return a reference to the jar and its classes - return@map Jar( - path = file, - classes = classes - ) - }?: emptyList() - - // Save the parsed jars and which folder - return@map Library( - jars = jars - ) - }?: emptyList() - - return@flatMap libraries - } - val meta = ObjectOutputStream(librariesMetaData.get().asFile.outputStream()) - meta.writeObject(output) - meta.close() - } -} \ No newline at end of file diff --git a/java/gradle/src/main/kotlin/ProcessingPlugin.kt b/java/gradle/src/main/kotlin/ProcessingPlugin.kt index 375b17549a..6e1d7d96f6 100644 --- a/java/gradle/src/main/kotlin/ProcessingPlugin.kt +++ b/java/gradle/src/main/kotlin/ProcessingPlugin.kt @@ -9,8 +9,11 @@ import org.gradle.api.model.ObjectFactory import org.gradle.api.plugins.JavaPlugin import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.tasks.JavaExec +import org.gradle.jvm.toolchain.JavaLanguageVersion import org.jetbrains.compose.ComposeExtension import org.jetbrains.compose.desktop.DesktopExtension +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.File import java.net.Socket import javax.inject.Inject @@ -23,7 +26,7 @@ class ProcessingPlugin @Inject constructor(private val objectFactory: ObjectFact val processingVersion = project.findProperty("processing.version") as String? ?: javaClass.classLoader.getResourceAsStream("version.properties")?.use { stream -> java.util.Properties().apply { load(stream) }.getProperty("version") - } ?: "4.3.4" + }?.takeIf { it != "unspecified" } ?: "4.5.5" val processingGroup = project.findProperty("processing.group") as String? ?: "org.processing" val workingDir = project.findProperty("processing.workingDir") as String? val debugPort = project.findProperty("processing.debugPort") as String? @@ -35,12 +38,24 @@ class ProcessingPlugin @Inject constructor(private val objectFactory: ObjectFact val settings = project.findProperty("processing.settings") as String? val root = project.findProperty("processing.root") as String? + val webgpu = (project.findProperty("processing.webgpu") as String?)?.toBoolean() ?: false + val javaVersion = if (webgpu) 25 else 17 + // Apply the Java plugin to the Project, equivalent of // plugins { // java // } project.plugins.apply(JavaPlugin::class.java) + project.extensions.configure(JavaPluginExtension::class.java) { ext -> + ext.toolchain { spec -> + spec.languageVersion.set(JavaLanguageVersion.of(javaVersion)) + } + } + project.tasks.withType(KotlinCompile::class.java).configureEach { task -> + task.compilerOptions.jvmTarget.set(JvmTarget.fromTarget(javaVersion.toString())) + } + if(isProcessing){ // Set the build directory to a temp file so it doesn't clutter up the sketch folder // Only if the build directory doesn't exist, otherwise proceed as normal @@ -98,6 +113,7 @@ class ProcessingPlugin @Inject constructor(private val objectFactory: ObjectFact // Set the class to be executed initially application.mainClass = sketchName application.nativeDistributions.includeAllModules = true + application.jvmArgs("--enable-native-access=ALL-UNNAMED") if(debugPort != null) { application.jvmArgs("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=$debugPort") } @@ -133,6 +149,9 @@ class ProcessingPlugin @Inject constructor(private val objectFactory: ObjectFact } } + val javaToolchains = project.extensions.getByType(org.gradle.jvm.toolchain.JavaToolchainService::class.java) + val launcher = javaToolchains.launcherFor { it.languageVersion.set(JavaLanguageVersion.of(javaVersion)) } + project.afterEvaluate { // Copy the result of create distributable to the project directory project.tasks.named("createDistributable") { task -> @@ -143,6 +162,13 @@ class ProcessingPlugin @Inject constructor(private val objectFactory: ObjectFact } } } + project.tasks.withType(JavaExec::class.java).configureEach { task -> + task.executable(launcher.get().executablePath.asFile.absolutePath) + task.jvmArgs("--enable-native-access=ALL-UNNAMED") + if (System.getProperty("os.name").lowercase().contains("mac")) { + task.jvmArgs("-XstartOnFirstThread") + } + } } // Move the processing variables into javaexec tasks so they can be used in the sketch as well @@ -151,6 +177,11 @@ class ProcessingPlugin @Inject constructor(private val objectFactory: ObjectFact .filterKeys { it.startsWith("processing") } .forEach { (key, value) -> task.systemProperty(key, value) } + task.jvmArgs("--enable-native-access=ALL-UNNAMED") + if (System.getProperty("os.name").lowercase().contains("mac")) { + task.jvmArgs("-XstartOnFirstThread") + } + // Connect the stdio to the PDE if ports are specified if(logPort != null) task.standardOutput = Socket("localhost", logPort.toInt()).outputStream if(errPort != null) task.errorOutput = Socket("localhost", errPort.toInt()).outputStream @@ -180,13 +211,6 @@ class ProcessingPlugin @Inject constructor(private val objectFactory: ObjectFact include("/*.java") } - // Scan the libraries before compiling the sketches - val librariesTaskName = sourceSet.getTaskName("scanLibraries", "PDE") - val librariesScan = project.tasks.register(librariesTaskName, LibrariesTask::class.java) { task -> - task.description = "Scans the libraries in the sketchbook" - task.libraryDirectories.from(sketchbook?.let { File(it, "libraries") }, root?.let { File(it).resolve("modes/java/libraries") }) - } - // Create a task to process the .pde files before compiling the java sources val pdeTaskName = sourceSet.getTaskName("preprocess", "PDE") val pdeTask = project.tasks.register(pdeTaskName, PDETask::class.java) { task -> @@ -198,19 +222,71 @@ class ProcessingPlugin @Inject constructor(private val objectFactory: ObjectFact sourceSet.java.srcDir(task.outputDirectory) } - val depsTaskName = sourceSet.getTaskName("addLegacyDependencies", "PDE") - project.tasks.register(depsTaskName, DependenciesTask::class.java){ task -> - // Link the output of the libraries task to the dependencies task - task.librariesMetaData.set(librariesScan.get().librariesMetaData) - task.dependsOn(pdeTask, librariesScan) - } + // Resolve sketch+library deps at config time. Adding deps from a + // TaskAction fails once a downstream task has already resolved + // runtimeClasspath (e.g. Compose's `run`). + addLegacyDependencies(project, pdeSourceSet.srcDirs, + listOfNotNull(sketchbook?.let { File(it, "libraries") }, + root?.let { File(it).resolve("modes/java/libraries") })) // Make sure that the PDE tasks runs before the java compilation task project.tasks.named(sourceSet.compileJavaTaskName) { task -> - task.dependsOn(pdeTaskName, depsTaskName) + task.dependsOn(pdeTaskName) } } } + private fun addLegacyDependencies(project: Project, sketchDirs: Set, libraryRoots: List) { + project.dependencies.add("runtimeOnly", "org.jogamp.jogl:jogl-all-main:2.6.0") + project.dependencies.add("runtimeOnly", "org.jogamp.gluegen:gluegen-rt:2.6.0") + + val os = System.getProperty("os.name").lowercase() + val arch = System.getProperty("os.arch").lowercase() + val variant = when { + os.contains("mac") -> "macosx-universal" + os.contains("win") && arch.contains("64") -> "windows-amd64" + os.contains("linux") && arch.contains("aarch64") -> "linux-aarch64" + os.contains("linux") && arch.contains("arm") -> "linux-arm" + os.contains("linux") && arch.contains("amd64") -> "linux-amd64" + else -> null + } + if (variant != null) { + project.dependencies.add("runtimeOnly", "org.jogamp.gluegen:gluegen-rt:2.6.0:natives-$variant") + project.dependencies.add("runtimeOnly", "org.jogamp.jogl:nativewindow:2.6.0:natives-$variant") + project.dependencies.add("runtimeOnly", "org.jogamp.jogl:newt:2.6.0:natives-$variant") + } + + // Reduce each import to its package prefix: `a.b.*` and `a.b.C` both become `a.b`, + // mirroring how the PDE preprocessor normalizes import statements + val imports = sketchDirs + .flatMap { dir -> dir.walkTopDown().filter { it.extension == "pde" }.toList() } + .flatMap { Regex("""^\s*import\s+(?:static\s+)?([\w.]+(?:\.\*)?)\s*;""", RegexOption.MULTILINE).findAll(it.readText()).map { m -> m.groupValues[1] } } + .map { it.removeSuffix(".*") } + .toSet() + if (imports.isEmpty()) return + + val libraryJars = libraryRoots + .filter { it.exists() } + .flatMap { it.listFiles { f -> f.isDirectory }?.toList() ?: emptyList() } + .mapNotNull { folder -> folder.resolve("library").takeIf { it.isDirectory } } + .flatMap { it.listFiles { f -> f.extension == "jar" }?.toList() ?: emptyList() } + + val matched = mutableSetOf() + imports.forEach { import -> + libraryJars.forEach { jar -> + java.util.jar.JarFile(jar).use { jf -> + val hit = jf.entries().asSequence() + .filter { it.name.endsWith(".class") } + .map { it.name.substringBeforeLast('/').replace('/', '.') } + .any { pkg -> pkg == import || pkg.startsWith("$import.") || import.startsWith("$pkg.") } + if (hit) matched.add(jar) + } + } + } + if (matched.isNotEmpty()) { + project.dependencies.add("implementation", project.files(matched)) + } + } + abstract class DefaultPDESourceDirectorySet @Inject constructor( sourceDirectorySet: SourceDirectorySet, taskDependencyFactory: TaskDependencyFactory diff --git a/java/preprocessor/src/main/antlr/processing/mode/java/preproc/Processing.g4 b/java/preprocessor/src/main/antlr/processing/mode/java/preproc/Processing.g4 index 2d4edc041a..2da79282e8 100644 --- a/java/preprocessor/src/main/antlr/processing/mode/java/preproc/Processing.g4 +++ b/java/preprocessor/src/main/antlr/processing/mode/java/preproc/Processing.g4 @@ -53,7 +53,7 @@ warnMixedModes variableDeclaratorId : warnTypeAsVariableName - | IDENTIFIER ('[' ']')* + | identifier ('[' ']')* ; // bug #93 @@ -68,7 +68,7 @@ warnTypeAsVariableName // catch special API function calls that we are interested in methodCall : functionWithPrimitiveTypeName - | IDENTIFIER '(' expressionList? ')' + | identifier '(' expressionList? ')' | THIS '(' expressionList? ')' | SUPER '(' expressionList? ')' ; @@ -103,7 +103,7 @@ colorPrimitiveType ; qualifiedName - : (IDENTIFIER | colorPrimitiveType) ('.' (IDENTIFIER | colorPrimitiveType))* + : (identifier | colorPrimitiveType) ('.' (identifier | colorPrimitiveType))* ; // added HexColorLiteral diff --git a/java/src/processing/mode/java/JavaBuild.java b/java/src/processing/mode/java/JavaBuild.java index b696ab0e20..af802c66ff 100644 --- a/java/src/processing/mode/java/JavaBuild.java +++ b/java/src/processing/mode/java/JavaBuild.java @@ -66,6 +66,7 @@ public class JavaBuild { private boolean foundMain = false; private String classPath; protected String sketchClassName; + protected String sketchRenderer; /** * This will include the code folder, any library folders, etc. that might @@ -118,6 +119,7 @@ public String build(File srcFolder, File binFolder, boolean sizeWarning) throws // that will bubble up to whomever called build(). if (Compiler.compile(this)) { sketchClassName = classNameFound; + sketchRenderer = result.getSketchRenderer(); return classNameFound; } return null; @@ -128,6 +130,10 @@ public String getSketchClassName() { return sketchClassName; } + public String getSketchRenderer() { + return sketchRenderer; + } + /** * Build all the code for this sketch. diff --git a/java/src/processing/mode/java/PreprocService.java b/java/src/processing/mode/java/PreprocService.java index 410cff02f6..a8a4a0c1c2 100644 --- a/java/src/processing/mode/java/PreprocService.java +++ b/java/src/processing/mode/java/PreprocService.java @@ -667,7 +667,7 @@ private void setupParser(boolean resolveBindings, String className, if (resolveBindings) { parser.setUnitName(className); - parser.setEnvironment(classPathArray, null, null, false); + parser.setEnvironment(classPathArray, null, null, true); parser.setResolveBindings(true); } } diff --git a/java/src/processing/mode/java/runner/Runner.java b/java/src/processing/mode/java/runner/Runner.java index a9baf1ea6a..d0bded04de 100644 --- a/java/src/processing/mode/java/runner/Runner.java +++ b/java/src/processing/mode/java/runner/Runner.java @@ -342,6 +342,10 @@ protected StringList getMachineParams() { // No longer needed / doesn't seem to do anything differently //params.append("-Dcom.apple.mrj.application.apple.menu.about.name=" + // build.getSketchClassName()); + + if ("WEBGPU".equals(build.getSketchRenderer())) { + params.append("-XstartOnFirstThread"); + } } /* if (Platform.isWindows()) { @@ -380,6 +384,10 @@ protected StringList getMachineParams() { // http://processing.org/bugs/bugzilla/1188.html params.append("-ea"); + // we need to open up access to internal jdk modules for libraries that use reflection + // this will break at some point in the future when these modules are removed from the jdk :( + params.append("--enable-native-access=ALL-UNNAMED"); + return params; } @@ -508,6 +516,9 @@ protected StringList getSketchParams(boolean present, String[] args) { } */ + // TODO: excise AWT to make webgpu work properly + params.append(PApplet.ARGS_DISABLE_AWT); + params.append(build.getSketchClassName()); } // Add command-line arguments to be given to the sketch itself diff --git a/java/test/processing/mode/java/ParserTests.java b/java/test/processing/mode/java/ParserTests.java index 9d589caaf8..9354c2048d 100644 --- a/java/test/processing/mode/java/ParserTests.java +++ b/java/test/processing/mode/java/ParserTests.java @@ -283,6 +283,11 @@ public void bug1532() { expectRecognitionException("bug1532", 43); } + @Test + public void bug1501() { + expectGood("bug1501"); + } + @Test public void bug1534() { expectGood("bug1534"); diff --git a/java/test/resources/bug1501.expected b/java/test/resources/bug1501.expected new file mode 100644 index 0000000000..b0e9ec5720 --- /dev/null +++ b/java/test/resources/bug1501.expected @@ -0,0 +1,51 @@ +import processing.core.*; +import processing.data.*; +import processing.event.*; +import processing.opengl.*; + +import java.util.HashMap; +import java.util.ArrayList; +import java.io.File; +import java.io.BufferedReader; +import java.io.PrintWriter; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.IOException; + +public class bug1501 extends PApplet { + +float to; +Module module; + +public void setup() { + /* size commented out by preprocessor */; + int open = 1; + String with = "with"; + to = 5.0f; + module = new Module(); + int transitive = open + 2; + println(to, with, transitive); + provides(); +} + +public void provides() { + int record = 2; + int permits = record + 1; + println(permits); +} + +class Module { +} + + + public void settings() { size(400, 400); } + + static public void main(String[] passedArgs) { + String[] appletArgs = new String[] { "bug1501" }; + if (passedArgs != null) { + PApplet.main(concat(appletArgs, passedArgs)); + } else { + PApplet.main(appletArgs); + } + } +} diff --git a/java/test/resources/bug1501.pde b/java/test/resources/bug1501.pde new file mode 100644 index 0000000000..c11f5f77c7 --- /dev/null +++ b/java/test/resources/bug1501.pde @@ -0,0 +1,22 @@ +float to; +Module module; + +void setup() { + size(400, 400); + int open = 1; + String with = "with"; + to = 5.0; + module = new Module(); + int transitive = open + 2; + println(to, with, transitive); + provides(); +} + +void provides() { + int record = 2; + int permits = record + 1; + println(permits); +} + +class Module { +} diff --git a/libprocessing b/libprocessing new file mode 160000 index 0000000000..77f105be73 --- /dev/null +++ b/libprocessing @@ -0,0 +1 @@ +Subproject commit 77f105be7307bc0b3552aba3cb9f24d1736aeda1 diff --git a/utils/contributors-png.js b/utils/contributors-png.js new file mode 100644 index 0000000000..2686f1685f --- /dev/null +++ b/utils/contributors-png.js @@ -0,0 +1,61 @@ +const { createCanvas, loadImage } = require('canvas'); +const fs = require('fs'); + +const data = fs.readFileSync('.all-contributorsrc', 'utf-8'); +const parsed = JSON.parse(data); +const contributors = parsed.contributors; + +const AVATAR_SIZE = 50; +const GAP = 4; +const COLS = 40; +const ROWS = Math.ceil(contributors.length / COLS); + +const width = COLS * AVATAR_SIZE + (COLS - 1) * GAP; +const height = ROWS * AVATAR_SIZE + (ROWS - 1) * GAP; + +const canvas = createCanvas(width, height); +const ctx = canvas.getContext('2d'); + +async function loadAvatar(url) { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const buffer = Buffer.from(await res.arrayBuffer()); + return await loadImage(buffer); + } catch (err) { + return null; + } +} + +(async () => { + for (let i = 0; i < contributors.length; i++) { + const c = contributors[i]; + + const col = i % COLS; + const row = Math.floor(i / COLS); + + const x = col * (AVATAR_SIZE + GAP); + const y = row * (AVATAR_SIZE + GAP); + + const img = await loadAvatar(c.avatar_url); + + ctx.save(); + ctx.beginPath(); + ctx.arc( + x + AVATAR_SIZE / 2, + y + AVATAR_SIZE / 2, + AVATAR_SIZE / 2, + 0, + Math.PI * 2 + ); + ctx.clip(); + + if (img) { + ctx.drawImage(img, x, y, AVATAR_SIZE, AVATAR_SIZE); + } + ctx.restore(); + } + + fs.writeFileSync('contributors.png', canvas.toBuffer('image/png')); +})();