From 79c6177c25845d51f15daeda032104d67c1b5c97 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 14 Aug 2026 09:51:03 +0200 Subject: [PATCH 1/8] feat: add loading spinner overlay Adds a full-screen spinner shown while long-running backend work is in flight, driven by an inbound "spinner" viewer command (visible + optional message). Mirrors compas_threejs's existing App.start_spinner()/stop_spinner() convention on the Python side. Co-Authored-By: Claude Sonnet 5 --- src/App.vue | 2 + src/components/layout/Spinner.vue | 177 ++++++++++++++++++++++++++++++ src/viewer/viewer_commands.ts | 19 +++- src/viewer/viewer_runtime.ts | 9 ++ src/viewer/viewer_store.ts | 5 + tests/viewer_commands.test.ts | 2 +- 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 src/components/layout/Spinner.vue diff --git a/src/App.vue b/src/App.vue index d9baa1b..e792bde 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,6 +6,7 @@
+ @@ -14,6 +15,7 @@ import { onMounted, ref } from "vue"; import ObjectInfo from "./components/layout/ObjectInfo.vue"; import Sidebar from "@/components/layout/Sidebar.vue"; import ThemeIndicator from "@/components/layout/ThemeIndicator.vue"; +import Spinner from "@/components/layout/Spinner.vue"; import type { ViewerRuntime } from "@/viewer/viewer_runtime"; const threeContainer = ref(null); diff --git a/src/components/layout/Spinner.vue b/src/components/layout/Spinner.vue new file mode 100644 index 0000000..f73e96e --- /dev/null +++ b/src/components/layout/Spinner.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/src/viewer/viewer_commands.ts b/src/viewer/viewer_commands.ts index 6903169..d8cabff 100644 --- a/src/viewer/viewer_commands.ts +++ b/src/viewer/viewer_commands.ts @@ -365,6 +365,12 @@ export type HandleGeometryCommand = | SetGeometryVisibilityCommand | ToggleGeometryVisibilityCommand; +export interface SpinnerCommand extends CommandRecord { + dispatch: "spinner"; + visible: boolean; + message?: string | null; +} + export type ViewerCommand = | MaterialCommand | LightCommand @@ -375,7 +381,8 @@ export type ViewerCommand = | TextTagCommand | ObjectInfosCommand | ObjectActionCommand - | HandleGeometryCommand; + | HandleGeometryCommand + | SpinnerCommand; const SCENE_TYPES = new Set([ "background_color", @@ -462,6 +469,9 @@ export function parseViewerCommand(record: CommandRecord): ViewerCommand { case "handle_geometry": validateHandleGeometry(record); return record as HandleGeometryCommand; + case "spinner": + validateSpinner(record); + return record as SpinnerCommand; default: throw new CompasViewerError( "unsupported_message", @@ -750,6 +760,13 @@ function validateHandleGeometry(record: CommandRecord): void { if (type === "set_visibility") readBoolean(record, "visible"); } +function validateSpinner(record: CommandRecord): void { + readBoolean(record, "visible"); + if (record.message !== undefined && record.message !== null) { + readOptionalString(record, "message"); + } +} + function readNumberFields(record: CommandRecord, fields: string[]): void { for (const field of fields) readFiniteNumber(record, field); } diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index b62e953..6bdf1af 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -20,6 +20,7 @@ import { type ObjectActionCommand, type ObjectInfosCommand, type SceneCommand, + type SpinnerCommand, type TextCommand, type TextTagCommand, type UiCommand, @@ -462,6 +463,9 @@ export class ViewerRuntime { case "handle_geometry": this.handleGeometry(data); break; + case "spinner": + this.manageSpinner(data); + break; } } @@ -650,6 +654,11 @@ export class ViewerRuntime { }); } + private manageSpinner(data: SpinnerCommand): void { + this.store.spinnerState.visible = data.visible; + this.store.spinnerState.message = data.visible ? (data.message ?? null) : null; + } + private pickFromPointer(event: MouseEvent): void { this.renderer.domElement.focus({ preventScroll: true }); if ( diff --git a/src/viewer/viewer_store.ts b/src/viewer/viewer_store.ts index 26e0ea7..347b6bd 100644 --- a/src/viewer/viewer_store.ts +++ b/src/viewer/viewer_store.ts @@ -85,6 +85,7 @@ export interface ViewerStore { blockPicker: { value: boolean }; showEdges: { value: boolean }; theme: { value: "light" | "dark" }; + spinnerState: { visible: boolean; message: string | null }; } export function createViewerStore(): ViewerStore { @@ -106,5 +107,9 @@ export function createViewerStore(): ViewerStore { blockPicker: reactive({ value: false }), showEdges: reactive({ value: false }), theme: reactive({ value: "light" as const }), + spinnerState: reactive({ + visible: false, + message: null as string | null, + }), }; } diff --git a/tests/viewer_commands.test.ts b/tests/viewer_commands.test.ts index f938c35..7d40bd0 100644 --- a/tests/viewer_commands.test.ts +++ b/tests/viewer_commands.test.ts @@ -41,7 +41,7 @@ describe("viewer command validation", () => { it("distinguishes unsupported dispatch and variant values", () => { for (const input of [ - { dispatch: "spinner", type: "show" }, + { dispatch: "not_a_real_dispatch", type: "show" }, { dispatch: "scene", type: "unknown_scene_action" }, ]) { expect(() => parseViewerCommand(input)).toThrowError( From d5ca393459b82e59d02f0a7176df90c27347f1a9 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 14 Aug 2026 10:06:21 +0200 Subject: [PATCH 2/8] update funny messages to non timber sepcific --- src/components/layout/Spinner.vue | 254 ++++++++++++++++++++++-------- 1 file changed, 184 insertions(+), 70 deletions(-) diff --git a/src/components/layout/Spinner.vue b/src/components/layout/Spinner.vue index f73e96e..45cf6f1 100644 --- a/src/components/layout/Spinner.vue +++ b/src/components/layout/Spinner.vue @@ -16,78 +16,192 @@ import { useViewerRuntime } from "@/viewer/viewer_context"; const { spinnerState } = useViewerRuntime().store; const FUNNY_MESSAGES = [ - "Counting the wooden beams...", - "Bribing the joints to hold still...", - "Sanding down the JSON splinters...", - "Asking the timber nicely to load faster...", - "Untangling knots (the wood kind, not the code kind)...", - "Convincing the beams they're not overreacting...", - "Measuring twice, sending once...", - "Waking up the sawmill...", - "Negotiating with a very large JSON file...", - "Stacking planks in the void...", - "Teaching the beams to stand up straight...", - "Looking for the missing screw...", - "Calibrating the virtual tape measure...", - "Checking if this beam is actually level...", - "Arguing with gravity...", - "Convincing the wood not to warp...", - "Waiting for the glue to dry...", - "Sharpening imaginary chisels...", - "Sweeping away virtual sawdust...", - "Assembling impossible geometry...", - "Finding the load-bearing coffee...", - "Making the timber grain cooperate...", - "Polishing the pixels...", - "Removing splinters from the algorithm...", - "Untying a stubborn knot...", - "Turning trees into data...", - "Counting growth rings...", - "Looking for that one missing bolt...", - "Aligning the universe to the nearest millimeter...", - "Making sure left is still left...", - "Teaching the CNC some manners...", - "Bringing the saw back from lunch...", - "Straightening crooked numbers...", - "Reassuring the timber that everything will be okay...", - "Checking for invisible termites...", - "Feeding the beavers...", - "Stacking pixels like plywood...", - "Sorting the sawdust alphabetically...", - "Compressing an entire forest...", - "Waiting for the carpenter to finish his coffee...", - "Consulting the ancient woodworking spirits...", - "Persuading trees to become architecture...", - "Downloading more lumber...", - "Replacing duct tape with engineering...", - "Converting coffee into structures...", - "Inflating the load-bearing walls...", - "Asking the squirrels for permission...", - "Waiting for the forest's approval...", - "Locating the beam distribution system...", - "Making wood remember it's a tree...", - "Generating extra grain...", - "Loading structural optimism...", - "Checking if the trees signed the consent form...", - "Teaching nails about personal space...", - "Making sure the beams are emotionally supported...", - "Debugging gravity...", - "Convincing physics to be flexible today...", - "Rotating the Earth for a better view...", - "Asking the pixels to hold still...", - "Waiting for the laws of mechanics to compile...", - "Running finite coffee analysis...", + "Consulting the geometry gods...", + "Asking the algorithm to calm down...", + "Convincing the computer this is intentional...", + "Counting things that probably don't need counting...", + "Making the polygons behave...", + "Untangling the spaghetti geometry...", + "Teaching vectors where to go...", + "Rotating things until they look right...", + "Checking if reality is still running...", + "Loading an unreasonable amount of geometry...", + "Negotiating with the CPU...", + "Blaming the mesh...", + "Adding more RAM, spiritually...", + "Making the triangles feel useful...", + "Convincing the vertices to cooperate...", + "Searching for the missing dimension...", + "Performing computational wizardry...", + "Sacrificing a GPU to the algorithm...", + "Asking the mesh nicely to be manifold...", + "Counting polygons instead of sheep...", + "Turning mathematics into architecture...", + "Turning architecture back into mathematics...", + "Making the computer question its life choices...", + "Checking whether this is actually a good idea...", + "Running several questionable calculations...", + "Applying advanced computational nonsense...", + "Removing unnecessary complexity by adding complexity...", + "Optimizing absolutely everything...", + "Optimizing something that was already fast...", + "Adding one more iteration...", + "Just one more iteration...", + "Okay, definitely the last iteration...", + "Pretending this will converge...", + "Waiting for convergence...", + "Negotiating with infinity...", + "Rounding numbers until they behave...", + "Arguing with floating-point arithmetic...", + "Trying to remember where we put zero...", + "Locating the origin...", + "Checking which way is up...", + "Verifying that 3D is still 3D...", + "Flattening things that should not be flattened...", + "Unflattening things that definitely should be flattened...", + "Making topology someone else's problem...", + "Searching for non-manifold nonsense...", + "Resolving existential intersections...", + "Making surfaces understand boundaries...", + "Asking the normals to face the right way...", + "Flipping normals and pretending nothing happened...", "Meshing reality...", - "Pretending floating-point errors don't exist...", - "Finding the center of mass...", - "Adding unnecessary fillets...", - "Optimizing away impossible constraints...", - "Removing non-manifold timber...", + "Remeshing reality...", + "Discretizing the universe...", + "Approximating perfection...", + "Generating controlled chaos...", + "Adding a little more randomness...", + "Removing suspicious randomness...", + "Randomizing the deterministic process...", + "Making deterministic randomness...", + "Running the forbidden loop...", + "Entering the computational abyss...", + "Checking what broke this time...", + "Finding the bug we introduced 20 minutes ago...", + "Looking for a missing comma...", + "Blaming JavaScript...", + "Blaming Python...", + "Blaming the GPU...", + "Blaming the user...", + "The computer knows what it did...", + "Have you tried turning the geometry off and on again?", + "Clearing the cache and our conscience...", + "Compiling some questionable decisions...", + "Waiting for the algorithm to have an idea...", + "Giving the CPU a moment to think...", + "Making the fans spin faster...", + "Converting electricity into polygons...", + "Converting polygons into more polygons...", + "Generating geometry nobody asked for...", + "Making unnecessary things parametric...", + "Parametrizing the obvious...", + "Overengineering a perfectly simple problem...", + "Adding another slider...", + "Adding sliders until it works...", + "Searching for the optimal number of sliders...", + "Making everything adjustable...", + "Making nothing adjustable...", + "Pretending the constraints are reasonable...", + "Negotiating with the constraints...", + "Relaxing the constraints...", + "Tightening the constraints...", + "Breaking the constraints...", + "Calling it a feature...", + "Calling it emergent behavior...", + "Calling it computational design...", + "Calling it architecture...", + "Generating plausible geometry...", + "Generating implausible geometry...", + "Making beautiful mistakes...", + "Turning mistakes into features...", + "Turning features into bugs...", + "Turning bugs into research...", + "Turning research into more bugs...", + "Approaching enlightenment...", + "Approaching the solution...", + "Approaching the deadline...", + "Running at 99% confidence...", + "Calculating with questionable precision...", + "Measuring things very precisely for no reason...", "Checking if 90° is still 90°...", - "Resolving existential intersections...", - "Converting sketches into regret...", - "Making the tolerances slightly more tolerant...", - "Regenerating parametric excuses...", + "Making sure left is still left...", + "Making sure up is still up...", + "Checking whether the dimensions agree...", + "Synchronizing the coordinate systems...", + "Convincing coordinate systems to get along...", + "Finding the center of everything...", + "Locating the important point...", + "Calculating the least important point...", + "Interpolating between bad decisions...", + "Extrapolating beyond our expertise...", + "Projecting our problems onto a surface...", + "Solving problems in higher dimensions...", + "Bringing everything back to 3D...", + "Reducing dimensional existential dread...", + "Applying unnecessary mathematics...", + "Applying necessary mathematics reluctantly...", + "Doing linear algebra so you don't have to...", + "Multiplying matrices aggressively...", + "Taking the dot product personally...", + "Crossing our fingers and our vectors...", + "Normalizing everything...", + "Checking the normals...", + "Uniting the vectors...", + "Dividing by something suspicious...", + "Avoiding division by zero...", + "Negotiating with NaN...", + "Convincing infinity to come back down...", + "Removing NaNs from polite society...", + "Hunting floating-point errors...", + "Rounding things irresponsibly...", + "Preserving numerical dignity...", + "Running finite coffee analysis...", + "Calculating the optimal coffee break...", + "Optimizing caffeine throughput...", + "Loading structural optimism...", + "Generating computational optimism...", + "Waiting for inspiration to compile...", + "Compiling inspiration...", + "Downloading more geometry...", + "Downloading additional dimensions...", + "Searching the internet for more RAM...", + "Consulting the documentation we should have read earlier...", + "Reading error messages very carefully...", + "Ignoring the error message...", + "Re-reading the error message...", + "Accepting our fate...", + "Almost there...", + "Definitely almost there...", + "Probably almost there...", + "This is taking longer than expected...", + "Doing something extremely important...", + "Doing something extremely computational...", + "Please remain geometrically calm...", + "Please do not touch anything...", + "Everything is under control...", + "Everything was under control...", + "Nothing to see here...", + "This is completely normal...", + "Trusting the process...", + "Trusting the algorithm...", + "Questioning the process...", + "Questioning the algorithm...", + "Reconsidering our life choices...", + "Adding more computational violence...", + "Brute-forcing elegance...", + "Searching for elegance...", + "Giving up on elegance...", + "Embracing chaos...", + "Rendering the consequences...", + "Calculating the consequences...", + "Preparing the consequences...", + "Generating something probably useful...", + "Turning pixels into problems...", + "Turning problems into pixels...", + "Making computers do architecture...", + "Making architecture do mathematics...", + "Making mathematics do the heavy lifting...", + "Almost done. Probably.", ]; const funnyMessage = ref(null); From 8293be43e75cf228ca8301a7bda75538b26a9a5b Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 14 Aug 2026 10:25:47 +0200 Subject: [PATCH 3/8] changelog and docs --- CHANGELOG.md | 7 +++++++ docs/support-matrix.md | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0940f0..3d6371d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog + +## Unreleased + +### Added + +- Added `Spinner` support for long-running operations. + ## [1.0.1](https://github.com/compas-dev/compas_threejs_ts/compare/v1.0.0...v1.0.1) (2026-08-13) ### Bug Fixes diff --git a/docs/support-matrix.md b/docs/support-matrix.md index a8c1704..cff114f 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -23,9 +23,9 @@ as with Python Breps. | Status | Commands | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| Included | Basic materials and lights, scene and theme settings, UI controls, text tags, metadata, object actions, removal, and visibility | +| Included | Basic materials and lights, scene and theme settings, UI controls, text tags, metadata, object actions, removal, and visibility, Spinner. | | Included callbacks | Object picking, UI actions, object actions, loaded JSON, and custom host actions | -| Deferred or limited | Spinner, text geometry fonts, advanced physical-material options, Sky, and RectAreaLight | +| Deferred or limited | Text geometry fonts, advanced physical-material options, Sky, and RectAreaLight | Python-to-browser commands use binary protobuf envelopes. Browser-to-Python callbacks use JSON text. From ba131dd0d2fb59a61d8f30b498e11a727c682e61 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 14 Aug 2026 11:10:10 +0200 Subject: [PATCH 4/8] docs: mark spinner as included in the 1.0 support matrix --- docs/support-matrix.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/support-matrix.md b/docs/support-matrix.md index cff114f..7e20b82 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -21,11 +21,11 @@ as with Python Breps. ## Commands -| Status | Commands | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| Included | Basic materials and lights, scene and theme settings, UI controls, text tags, metadata, object actions, removal, and visibility, Spinner. | -| Included callbacks | Object picking, UI actions, object actions, loaded JSON, and custom host actions | -| Deferred or limited | Text geometry fonts, advanced physical-material options, Sky, and RectAreaLight | +| Status | Commands | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Included | Basic materials and lights, scene and theme settings, UI controls, text tags, metadata, object actions, removal, visibility, and spinner | +| Included callbacks | Object picking, UI actions, object actions, loaded JSON, and custom host actions | +| Deferred or limited | Text geometry fonts, advanced physical-material options, Sky, and RectAreaLight | Python-to-browser commands use binary protobuf envelopes. Browser-to-Python callbacks use JSON text. From 2c19a2e53b53976720125dae818adf05d0877818 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 14 Aug 2026 15:29:07 +0200 Subject: [PATCH 5/8] prettier --- src/components/layout/Spinner.vue | 14 +++++++++++--- src/viewer/viewer_runtime.ts | 4 +++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/layout/Spinner.vue b/src/components/layout/Spinner.vue index 45cf6f1..80ccff1 100644 --- a/src/components/layout/Spinner.vue +++ b/src/components/layout/Spinner.vue @@ -1,9 +1,17 @@ diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index 6bdf1af..b5c5f8e 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -656,7 +656,9 @@ export class ViewerRuntime { private manageSpinner(data: SpinnerCommand): void { this.store.spinnerState.visible = data.visible; - this.store.spinnerState.message = data.visible ? (data.message ?? null) : null; + this.store.spinnerState.message = data.visible + ? (data.message ?? null) + : null; } private pickFromPointer(event: MouseEvent): void { From 3b79cf2a793a661abc746d4464877da8bc272f56 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 14 Aug 2026 15:33:53 +0200 Subject: [PATCH 6/8] fix: rename Spinner.vue to GlobalSpinner.vue for multi-word component names The eslint-plugin-vue "multi-word-component-names" rule only grandfathers Openbar/Sidebar/Toolbar and the vendored shadcn-ui primitives; new components are expected to use multi-word names rather than be added to that exemption list. Co-Authored-By: Claude Sonnet 5 --- src/App.vue | 4 ++-- src/components/layout/{Spinner.vue => GlobalSpinner.vue} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src/components/layout/{Spinner.vue => GlobalSpinner.vue} (100%) diff --git a/src/App.vue b/src/App.vue index e792bde..271aea2 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,7 +6,7 @@
- + @@ -15,7 +15,7 @@ import { onMounted, ref } from "vue"; import ObjectInfo from "./components/layout/ObjectInfo.vue"; import Sidebar from "@/components/layout/Sidebar.vue"; import ThemeIndicator from "@/components/layout/ThemeIndicator.vue"; -import Spinner from "@/components/layout/Spinner.vue"; +import GlobalSpinner from "@/components/layout/GlobalSpinner.vue"; import type { ViewerRuntime } from "@/viewer/viewer_runtime"; const threeContainer = ref(null); diff --git a/src/components/layout/Spinner.vue b/src/components/layout/GlobalSpinner.vue similarity index 100% rename from src/components/layout/Spinner.vue rename to src/components/layout/GlobalSpinner.vue From eb32dfb7892392e46bf5fbc2f45a33071e4a19f4 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 17 Aug 2026 13:25:59 +0200 Subject: [PATCH 7/8] feat: `Spinner` component for long running task. --- .gitattributes | 6 +++ .githooks/commit-msg | 45 ++++++++++++++++++++ .githooks/conventional-commit.sh | 18 ++++++++ .github/pull_request_template.md | 13 ++++++ .github/workflows/pr-checks.yml | 73 ++++++++++++++++++++++++++++++++ .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +-- package-lock.json | 4 +- package.json | 3 +- src/viewer/viewer_runtime.ts | 29 ++++++++++--- tests/viewer_lifecycle.test.ts | 22 ++++++++++ 11 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 .gitattributes create mode 100755 .githooks/commit-msg create mode 100644 .githooks/conventional-commit.sh create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/pr-checks.yml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ab23ce6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Git hooks run under the bash bundled with Git for Windows, which cannot +# execute a script that has CRLF line endings. Because a commit-msg hook that +# fails to start blocks every commit, keep these files LF on all platforms +# regardless of the contributor's core.autocrlf setting. +.githooks/** text eol=lf +*.sh text eol=lf diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..4bca334 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# Reject a commit subject that Release Please cannot turn into a changelog +# entry. Enabled by the `prepare` script in package.json, which points +# core.hooksPath at this directory. +# +# Use `git commit --no-verify` to bypass it deliberately. + +set -e + +hooks_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +. "$hooks_dir/conventional-commit.sh" + +# The subject is the first line that is neither a comment nor leading blank. +subject=$(grep -v '^#' "$1" | sed '/./,$!d' | head -n 1) + +if is_conventional_commit "$subject"; then + exit 0 +fi + +cat >&2 < + +The title must be a [conventional commit](https://www.conventionalcommits.org): +`fix:`, `feat:`, `feat!:` for a breaking change, or one of `build:` `chore:` +`ci:` `docs:` `perf:` `refactor:` `style:` `test:`. Release Please builds +`CHANGELOG.md` and the next version number from it. Apply the `no changelog` +label to skip. Running `npm install` enables a `commit-msg` hook that checks +your commits as you make them. + +### Checklist + +- [ ] `npm run check` is green (formatting, lint, types, unit tests, both builds). +- [ ] `npm run test:browser` and `npm run test:package` pass. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..9968f12 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,73 @@ +name: pr-checks + +on: + pull_request: + types: [opened, synchronize, reopened, edited, labeled, unlabeled] + branches: + - main + +permissions: + contents: read + +concurrency: + group: pr-checks-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + changelog: + name: Check changelog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + # Release Please generates CHANGELOG.md from conventional commits, so a + # pull request earns its changelog entry by using them, not by editing + # the generated file. + # + # The title is an error because it is one edit away from correct, and it + # is what a squash merge records. Commit subjects are only warnings, + # because fixing them means a rebase and a force push; the commit-msg + # hook in .githooks is what keeps them clean. + - name: Require conventional commits + env: + PR_TITLE: ${{ github.event.pull_request.title }} + SKIP_LABEL: no changelog + run: | + . .githooks/conventional-commit.sh + + if jq --exit-status --arg label "$SKIP_LABEL" \ + 'any(.pull_request.labels[]?; .name == $label)' \ + "$GITHUB_EVENT_PATH" >/dev/null; then + echo "Changelog check skipped by label: $SKIP_LABEL" + exit 0 + fi + + base_sha=$(jq --raw-output '.pull_request.base.sha' "$GITHUB_EVENT_PATH") + head_sha=$(jq --raw-output '.pull_request.head.sha' "$GITHUB_EVENT_PATH") + + for commit in "$base_sha" "$head_sha"; do + if ! git cat-file -e "$commit^{commit}" 2>/dev/null; then + git fetch --no-tags --depth=1 origin "$commit" + fi + done + + unconventional=0 + while IFS=$'\t' read -r sha subject; do + if ! is_conventional_commit "$subject"; then + echo "::warning::Commit ${sha:0:8} is not a conventional commit: $subject" + unconventional=$((unconventional + 1)) + fi + done < <(git log --no-merges --format='%H%x09%s' "$base_sha..$head_sha") + + if [[ "$unconventional" -gt 0 ]]; then + echo "::warning::$unconventional commit subject(s) will be missing from CHANGELOG.md. Enable the local hook with 'npm install' to catch these before they are committed." + fi + + if [[ ! "$PR_TITLE" =~ $CONVENTIONAL_COMMIT_PATTERN ]]; then + echo "::error::Pull request title is not a conventional commit: $PR_TITLE" + echo "::error::Retitle this pull request with a type prefix such as 'fix: ', 'feat: ' or 'docs: ', or apply the '$SKIP_LABEL' label" + exit 1 + fi diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 8d7e5f1..b870c5e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.1" + ".": "1.0.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6371d..01f1017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ # Changelog +## [1.0.2](https://github.com/compas-dev/compas_threejs_ts/compare/v1.0.1...v1.0.2) (2026-08-17) -## Unreleased -### Added +### Bug Fixes -- Added `Spinner` support for long-running operations. +* allow anonymous geometry (geometry without GUIDs) ([2b760e2](https://github.com/compas-dev/compas_threejs_ts/commit/2b760e27934d5661f71c28328f341910d86f2859)) +* render geometry without external guid ([80e95ac](https://github.com/compas-dev/compas_threejs_ts/commit/80e95ac7623bbeb5d79f32303b3b3e860a802d59)) ## [1.0.1](https://github.com/compas-dev/compas_threejs_ts/compare/v1.0.0...v1.0.1) (2026-08-13) diff --git a/package-lock.json b/package-lock.json index b7b929c..c5dabca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@compas-dev/compas-threejs-ts", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@compas-dev/compas-threejs-ts", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "dependencies": { "vue": "^3.5.41" diff --git a/package.json b/package.json index 65063a1..8087af9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@compas-dev/compas-threejs-ts", - "version": "1.0.1", + "version": "1.0.2", "description": "A Three.js viewer for COMPAS using TypeScript and Protobuf", "license": "MIT", "repository": { @@ -55,6 +55,7 @@ "test:package": "node scripts/test-package.mjs", "check": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build:app && npm run build:library", "audit:prod": "npm audit --omit=dev --audit-level=high", + "prepare": "git config core.hooksPath .githooks || true", "prepublishOnly": "npm run check && npm run audit:prod", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix", diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index b5c5f8e..9f812d5 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -104,6 +104,10 @@ export class ViewerRuntime { private readonly materials = new Map(); private readonly geometryMaterials = new Map(); + private readonly externalGeometryGuids = new WeakMap< + THREE.Object3D, + string + >(); private readonly lights = new Map(); private readonly fonts = new Map(); private readonly axesHelper = new THREE.AxesHelper(5); @@ -470,16 +474,20 @@ export class ViewerRuntime { } private manageGeometry(object: CommandRecord): void { - const guid = readNonEmptyString(object, "guid"); const converted = convertToThreeJSGeometry(object); - const existing = this.geometries.get(guid); + const externalGuid = resolveExternalGeometryGuid(object); + const sceneKey = externalGuid ?? converted.uuid; + const existing = this.geometries.get(sceneKey); if (existing) { this.scene.remove(existing); this.disposeObject(existing); } - this.applyGeometryMaterial(guid, converted); + if (externalGuid) { + this.externalGeometryGuids.set(converted, externalGuid); + } + this.applyGeometryMaterial(sceneKey, converted); this.scene.add(converted); - this.geometries.set(guid, converted); + this.geometries.set(sceneKey, converted); if (this.store.showEdges.value && converted instanceof THREE.Mesh) { const edges = new THREE.LineSegments( new THREE.EdgesGeometry(converted.geometry), @@ -719,8 +727,10 @@ export class ViewerRuntime { private findGeometryGuid(object: THREE.Object3D): string | undefined { let current: THREE.Object3D | null = object; while (current) { - for (const [guid, candidate] of this.geometries) { - if (candidate === current) return guid; + for (const candidate of this.geometries.values()) { + if (candidate === current) { + return this.externalGeometryGuids.get(candidate); + } } current = current.parent; } @@ -1060,3 +1070,10 @@ export class ViewerRuntime { } } } + +function resolveExternalGeometryGuid( + object: CommandRecord, +): string | undefined { + if (object.guid === undefined || object.guid === "") return undefined; + return readNonEmptyString(object, "guid"); +} diff --git a/tests/viewer_lifecycle.test.ts b/tests/viewer_lifecycle.test.ts index 80f827a..e6874c5 100644 --- a/tests/viewer_lifecycle.test.ts +++ b/tests/viewer_lifecycle.test.ts @@ -289,4 +289,26 @@ describe("createViewer", () => { expect(registeredDispose).toHaveBeenCalledOnce(); }); + + it("renders geometry without an external GUID", () => { + const container = document.createElement("div"); + document.body.append(container); + const runtime = new ViewerRuntime(container, { mode: "embedded" }); + runtime.attach(container); + + runtime.dispatch(boxBytes("")); + runtime.dispatch(boxBytes("")); + + expect(runtime.geometries.size).toBe(2); + for (const [sceneKey, object] of runtime.geometries) { + expect(sceneKey).toBe(object.uuid); + } + + runtime.dispatch(boxBytes("addressable-box")); + runtime.dispatch(boxBytes("addressable-box")); + expect(runtime.geometries.size).toBe(3); + expect(runtime.geometries.has("addressable-box")).toBe(true); + + runtime.dispose(); + }); }); From d19a849db91afe26546405b1d98524d0372a4a66 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 17 Aug 2026 15:32:26 +0200 Subject: [PATCH 8/8] Merge remote-tracking branch 'origin/main' into feature/spinner --- src/components/layout/ObjectInfo.vue | 26 +++++++++++-- src/components/tools/display/DisplayGroup.vue | 2 + .../tools/display/ShowAllObjectsButton.vue | 37 +++++++++++++++++++ src/components/tools/display/index.ts | 1 + src/viewer/viewer_runtime.ts | 29 ++++++++++++++- src/viewer/viewer_store.ts | 2 + 6 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 src/components/tools/display/ShowAllObjectsButton.vue diff --git a/src/components/layout/ObjectInfo.vue b/src/components/layout/ObjectInfo.vue index d69a6cd..42c7275 100644 --- a/src/components/layout/ObjectInfo.vue +++ b/src/components/layout/ObjectInfo.vue @@ -12,7 +12,7 @@ class="text-lg font-bold section-title" :class="{ dark: theme.value === 'dark' }" > - METADATA + Metadata
- FUNCTIONS + Functions +
+ +
runtime.handleObjectAction({ ...action }, value); const infoPanel = ref(null); +function handleHide() { + if (!selectedObjectGuid.value) return; + runtime.hideObjectByGuid(selectedObjectGuid.value); + runtime.deselectObject(); +} + const { isHovered } = useHover(infoPanel); watchEffect(() => { diff --git a/src/components/tools/display/DisplayGroup.vue b/src/components/tools/display/DisplayGroup.vue index 5182d46..e38797d 100644 --- a/src/components/tools/display/DisplayGroup.vue +++ b/src/components/tools/display/DisplayGroup.vue @@ -12,6 +12,7 @@ @delete="deleteSavedView" /> +
@@ -25,6 +26,7 @@ import { SaveViewButton, SavedViewsButton, SaveScreenshotButton, + ShowAllObjectsButton, } from "./index"; const SAVED_VIEWS_STORAGE_KEY = "compas_threejs_saved_views"; diff --git a/src/components/tools/display/ShowAllObjectsButton.vue b/src/components/tools/display/ShowAllObjectsButton.vue new file mode 100644 index 0000000..8e7ab87 --- /dev/null +++ b/src/components/tools/display/ShowAllObjectsButton.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/tools/display/index.ts b/src/components/tools/display/index.ts index b8a1e4c..d1299f5 100644 --- a/src/components/tools/display/index.ts +++ b/src/components/tools/display/index.ts @@ -1,3 +1,4 @@ export { default as SaveViewButton } from "./SaveViewButton.vue"; export { default as SavedViewsButton } from "./SavedViewsButton.vue"; export { default as SaveScreenshotButton } from "./SaveScreenshotButton.vue"; +export { default as ShowAllObjectsButton } from "./ShowAllObjectsButton.vue"; diff --git a/src/viewer/viewer_runtime.ts b/src/viewer/viewer_runtime.ts index 9f812d5..85c7a5c 100644 --- a/src/viewer/viewer_runtime.ts +++ b/src/viewer/viewer_runtime.ts @@ -127,6 +127,7 @@ export class ViewerRuntime { private disposed = false; private pickedObject: THREE.Object3D | null = null; private pickedMaterial: THREE.Material | THREE.Material[] | null = null; + private readonly hiddenGuids = new Set(); private readonly highlightMaterial = new THREE.MeshStandardMaterial({ color: "orange", emissive: "yellow", @@ -280,6 +281,25 @@ export class ViewerRuntime { this.store.objectBarData.isVisible = false; } + hideObjectByGuid(guid: string): void { + const object = this.geometries.get(guid); + if (!object) return; + object.visible = false; + this.hiddenGuids.add(guid); + } + + showAllObjects(): void { + for (const guid of this.hiddenGuids) { + const object = this.geometries.get(guid); + if (object) object.visible = true; + } + this.hiddenGuids.clear(); + } + + deselectObject(): void { + this.clearPickedObject(); + } + setTransformMode(mode: "translate" | "rotate" | "scale"): void { this.store.pickerMode.value = mode; this.transformControls.setMode(mode); @@ -361,6 +381,7 @@ export class ViewerRuntime { this.disposeObject(object); } this.geometries.clear(); + this.hiddenGuids.clear(); this.clearLights(); for (const entry of this.materials.values()) entry.material.dispose(); this.materials.clear(); @@ -706,7 +727,10 @@ export class ViewerRuntime { } this.transformControls.attach(picked); const guid = this.findGeometryGuid(picked); - if (guid) this.sendData({ dispatch: "object_picked", guid }); + if (guid) { + this.store.selectedObjectGuid.value = guid; + this.sendData({ dispatch: "object_picked", guid }); + } } private clearPickedObject(): void { @@ -722,6 +746,7 @@ export class ViewerRuntime { this.transformControls.detach(); this.store.objectBarData.data = null; this.store.objectActionsState.splice(0); + this.store.selectedObjectGuid.value = null; } private findGeometryGuid(object: THREE.Object3D): string | undefined { @@ -824,6 +849,7 @@ export class ViewerRuntime { this.disposeObject(object); this.geometries.delete(guid); this.geometryMaterials.delete(guid); + this.hiddenGuids.delete(guid); } else if (data.type === "set_visibility") { object.visible = data.visible; } else if (data.type === "toggle_visibility") { @@ -1032,6 +1058,7 @@ export class ViewerRuntime { private resetAfterDispose(): void { for (const object of this.geometries.values()) this.disposeObject(object); this.geometries.clear(); + this.hiddenGuids.clear(); this.clearLights(); for (const entry of this.materials.values()) entry.material.dispose(); this.materials.clear(); diff --git a/src/viewer/viewer_store.ts b/src/viewer/viewer_store.ts index 347b6bd..fc6db18 100644 --- a/src/viewer/viewer_store.ts +++ b/src/viewer/viewer_store.ts @@ -86,6 +86,7 @@ export interface ViewerStore { showEdges: { value: boolean }; theme: { value: "light" | "dark" }; spinnerState: { visible: boolean; message: string | null }; + selectedObjectGuid: { value: string | null }; } export function createViewerStore(): ViewerStore { @@ -111,5 +112,6 @@ export function createViewerStore(): ViewerStore { visible: false, message: null as string | null, }), + selectedObjectGuid: reactive({ value: null as string | null }), }; }